diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 27011d44..515253e8 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "agmsg", "description": "Cross-agent messaging via SQLite. Send messages between CLI AI agents. No daemon, no network.", - "version": "1.1.6", + "version": "1.1.8", "author": { "name": "fujibee" }, diff --git a/.github/workflows/app-release.yml b/.github/workflows/app-release.yml index 23c45316..16590b38 100644 --- a/.github/workflows/app-release.yml +++ b/.github/workflows/app-release.yml @@ -23,10 +23,13 @@ name: app release # (the signing identity itself is hardcoded in tauri.conf.json, not a # secret — don't set APPLE_SIGNING_IDENTITY as a step env var unless it's # actually populated, an empty override breaks codesign) -# Windows codesign (azure/artifact-signing-action, OIDC federated via -# azure/login — no client secret. Account: agmsg-artifact-signing, East US, -# endpoint https://eus.codesigning.azure.net/, certificate profile -# agmsg-app-signing (Public Trust, CN=Koichi Fujikawa). App registration: +# Windows codesign (Azure Trusted Signing via the TrustedSigning PowerShell +# module, wired into `tauri build` through bundle.windows.signCommand -> +# app/scripts/sign-windows.ps1 so signing happens BEFORE cab packing and +# updater .sig generation (#333). OIDC federated via azure/login — no +# client secret. Account: agmsg-artifact-signing, East US, endpoint +# https://eus.codesigning.azure.net/, certificate profile agmsg-app-signing +# (Public Trust, CN=Koichi Fujikawa). App registration: # agmsg-github-actions, federated for the main branch): # AZURE_CLIENT_ID / AZURE_TENANT_ID / AZURE_SUBSCRIPTION_ID # Auto-updater artifact signing (keypair already generated locally, see @@ -186,12 +189,13 @@ jobs: working-directory: . shell: bash - - name: Build - env: - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - run: pnpm tauri build - + # Login BEFORE the build: Authenticode signing now happens inside + # `tauri build` (bundle > windows > signCommand -> sign-windows.ps1), so + # the Azure CLI credential must already exist when the bundler runs. + # Signing binaries after the build (the old post-build signing step, + # since removed) mutated bytes the updater's minisign .sig had already been + # computed over — every 0.1.4 update failed signature verification, and + # the in-place pass also corrupted the MSI's embedded cabinet (#333). - name: Azure login (OIDC) if: ${{ env.HAS_AZURE_CREDS == 'true' }} uses: azure/login@v3 @@ -200,19 +204,44 @@ jobs: tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - - name: Sign Windows binaries (Azure Trusted Signing) + - name: Install TrustedSigning PowerShell module if: ${{ env.HAS_AZURE_CREDS == 'true' }} - uses: azure/artifact-signing-action@v2 - with: - endpoint: https://eus.codesigning.azure.net/ - signing-account-name: agmsg-artifact-signing - certificate-profile-name: agmsg-app-signing - files-folder: ${{ github.workspace }}/app/src-tauri/target/release/bundle - files-folder-filter: exe,msi - files-folder-recurse: true - file-digest: SHA256 - timestamp-rfc3161: http://timestamp.acs.microsoft.com - timestamp-digest: SHA256 + shell: pwsh + run: Install-Module -Name TrustedSigning -Force -Scope CurrentUser -Repository PSGallery + + - name: Build + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + shell: bash + run: | + # With Azure creds, sign each artifact during bundling: the app exe + # is signed before it is packed into the MSI cab / NSIS installer, + # and the updater .sig is generated over the final signed bytes. + # Feature branches build unsigned (federated identity trusts main + # only, so there is no credential to sign with). + if [ "$HAS_AZURE_CREDS" = "true" ]; then + sign_script="${GITHUB_WORKSPACE//\\//}/app/scripts/sign-windows.ps1" + config="$(jq -cn --arg cmd "pwsh -NoProfile -ExecutionPolicy Bypass -File $sign_script %1" '{bundle: {windows: {signCommand: $cmd}}}')" + pnpm tauri build --config "$config" + else + pnpm tauri build + fi + + - name: Verify Authenticode signatures + if: ${{ env.HAS_AZURE_CREDS == 'true' }} + shell: pwsh + run: | + $shipped = Get-ChildItem -Path src-tauri/target/release/bundle/msi/*.msi, src-tauri/target/release/bundle/nsis/*.exe + if ($shipped.Count -eq 0) { Write-Error "no shipped artifacts found to verify"; exit 1 } + foreach ($f in $shipped) { + $sig = Get-AuthenticodeSignature -FilePath $f.FullName + if ($sig.Status -ne 'Valid') { + Write-Error "$($f.Name): Authenticode status $($sig.Status) — signCommand did not run or failed silently" + exit 1 + } + Write-Output "OK: $($f.Name) signed by $($sig.SignerCertificate.Subject)" + } - uses: actions/upload-artifact@v4 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index d8f64590..383ac46a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,49 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.8] - 2026-07-15 + +### Added +- Sidebar per-section + buttons, replacing the New dropdown (#407) +- Green status-unknown default, roomier team-status-rail rows (#406) +- Phase-lock agent-status and monitor pulse dots to wall clock (#403) +- Detect grok/grok-build agent status (#395) +- Persist UI settings across restarts (#391) +- Snap pane dividers to terminal cell units, herdr-style gaps (#390) +- Show agent and team status (#385) + +### Fixed +- Filter non-numeric characters out of the font-size draft (#405) +- Reject unregistered from/to agents (#409) +- Resolve Codex leftovers and delivery_modes mismatch (#408) +- Forward args on a flags-only monitored launch (#404) +- Clean up the Settings font-size input (#401) +- Display chat timestamps in local time, not raw UTC (#394) +- Normalise Windows backslash paths before handing to bash/curl (#392) + +### Performance +- Batch pty-output writes to one term.write() per animation frame (#402) + +## [app-v0.1.5] - 2026-07-13 + +### Added +- 0.1.5 UI polish — sidebar collapse, chat pane min/max, Team Room toggle, About version, lucide icons (#377) + +### Fixed +- Authenticode-sign Windows binaries during tauri build (#354) + +## [1.1.7] - 2026-07-13 + +### Added +- Role-to-session affinity: named sessions, resume-by-role boot, tmux-resurrect (#339) (#344) + +### Fixed +- Wrap boot script with bash -l for psmux on Windows (#335) (#363) +- Guard '/'-prefixed boot prompt against MSYS path conversion on Git Bash (#358) +- Stop ancestor project resolution from over-reaching to $HOME / other teams (#357) (#359) +- Bind the bridge to the role's recorded thread, not "loaded" (#350) (#353) +- Detect the real GEMINI_CLI env var, not GOOGLE_GEMINI_CLI (#351) + ## [1.1.6] - 2026-07-05 ### Added @@ -233,6 +276,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Handle empty TaskList explicitly to stop fresh-session loop (#71) - Storage driver pluginization design (epic #51) (#52) +[1.1.8]: https://github.com/fujibee/agmsg/compare/app-v0.1.5...v1.1.8 +[app-v0.1.5]: https://github.com/fujibee/agmsg/compare/v1.1.7...app-v0.1.5 +[1.1.7]: https://github.com/fujibee/agmsg/compare/app-v0.1.4...v1.1.7 [1.1.6]: https://github.com/fujibee/agmsg/compare/app-v0.1.3...v1.1.6 [app-v0.1.3]: https://github.com/fujibee/agmsg/compare/app-v0.1.2...app-v0.1.3 [app-v0.1.2]: https://github.com/fujibee/agmsg/compare/app-v0.1.1...app-v0.1.2 diff --git a/README.ja.md b/README.ja.md index 638f0050..119a5d64 100644 --- a/README.ja.md +++ b/README.ja.md @@ -317,7 +317,7 @@ $agmsg ### シェル(任意のエージェント) ```bash -~/.agents/skills//scripts/send.sh "" +~/.agents/skills//scripts/send.sh "" [--force] ~/.agents/skills//scripts/inbox.sh ~/.agents/skills//scripts/history.sh [agent_id] [limit] ~/.agents/skills//scripts/team.sh @@ -327,7 +327,7 @@ $agmsg ~/.agents/skills//scripts/reset.sh [agent_id] ``` -`send.sh` はちょうど4つの位置引数を取る: ` ""`。シェルが1つの引数として認識するようメッセージはクォートすること — クォートされていないスペース入りメッセージは誤って分割される。 +`send.sh` は4つの位置引数 ` ""` に加えて、末尾に任意で `--force` を取る。シェルが1つの引数として認識するようメッセージはクォートすること — クォートされていないスペース入りメッセージは誤って分割される。`from`・`to` はどちらも `` に事前登録済みである必要があり、未登録の名前は(登録済み一覧を添えて)エラーになる — 意図的な事前登録前送信をしたい場合のみ `--force` でこのチェックを迂回できる。 ## FAQ / 設計メモ diff --git a/README.md b/README.md index 564a5620..e6fb49aa 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,19 @@ By default `despawn ` is **graceful**: it sends a `ctrl:despawn` control m Despawn only acts on the named member — the session running `despawn` is never torn down, and a broad-subscription watcher ignores a `ctrl:despawn` aimed at another role. +### Bring a role back with its context (session resume) + +A role remembers the session that last embodied it: sessions are named +`-`, and `spawn` **resumes a role's previous session by default** — +so re-spawning after a `despawn`, crash, or restart comes back in the prior +conversation, not blank (`--fresh` forces new). With +[tmux-resurrect](https://github.com/tmux-plugins/tmux-resurrect), one `~/.tmux.conf` +line re-seats every role pane into its session after a tmux-server restart. + +See **[docs/session-resurrect.md](docs/session-resurrect.md)** for the tmux-resurrect +setup, how it resolves each pane, what does and doesn't come back automatically, and +the manual fallback. + ## Delivery modes How incoming messages reach your agent. Pick one at first join via the prompt, or change it later with `/agmsg mode `. @@ -318,7 +331,7 @@ See [docs/opencode.md](docs/opencode.md) for full setup instructions. ### Shell (any agent) ```bash -~/.agents/skills//scripts/send.sh "" +~/.agents/skills//scripts/send.sh "" [--force] ~/.agents/skills//scripts/inbox.sh ~/.agents/skills//scripts/history.sh [agent_id] [limit] ~/.agents/skills//scripts/team.sh @@ -328,7 +341,7 @@ See [docs/opencode.md](docs/opencode.md) for full setup instructions. ~/.agents/skills//scripts/reset.sh [agent_id] ``` -`send.sh` takes exactly four positional arguments: ` ""`. Quote the message so the shell sees it as one argument; an unquoted message with spaces will be misparsed. +`send.sh` takes four positional arguments — ` ""` — plus an optional trailing `--force`. Quote the message so the shell sees it as one argument; an unquoted message with spaces will be misparsed. Both `from` and `to` must already be registered in ``; an unregistered name errors out (listing the currently registered names) instead of silently storing an undeliverable message. Pass `--force` to bypass this check for an intentional pre-registration send. ## FAQ / Design notes diff --git a/SKILL.md b/SKILL.md index 3df656fb..402f2a8b 100644 --- a/SKILL.md +++ b/SKILL.md @@ -7,6 +7,8 @@ description: Cross-agent messaging via SQLite. Send messages between Claude Code **IMPORTANT: Always use the provided scripts. NEVER directly read or edit config files, DB, or team data. There is NO register.sh — use join.sh to join a team.** +**Shell requirement:** All agmsg scripts are Bash scripts. Always execute them via `bash`, never via PowerShell or cmd directly. If your default shell is not Bash (e.g. PowerShell on Windows), wrap every command with `bash -lc '...'`. Example: `bash -lc '~/.agents/skills/agmsg/scripts/send.sh myteam alice bob "hello"'`. Do NOT construct DB paths manually — the scripts handle path resolution internally. If you need to redirect storage, use `AGMSG_STORAGE_PATH` (the supported override). + ## How to use ### Step 0: First-run bootstrap @@ -56,8 +58,8 @@ Do NOT manually edit config files. Always use join.sh. # Check inbox (marks messages as read) — DEFAULT action ~/.agents/skills/agmsg/scripts/inbox.sh -# Send a message -~/.agents/skills/agmsg/scripts/send.sh "" +# Send a message (from/to must already be registered in ; add --force to bypass) +~/.agents/skills/agmsg/scripts/send.sh "" [--force] # Message history ~/.agents/skills/agmsg/scripts/history.sh [agent_id] [limit] diff --git a/VERSION b/VERSION index 0664a8fd..18efdb9a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.6 +1.1.8 diff --git a/app/AGMSG_CORE_REF b/app/AGMSG_CORE_REF index 650b706f..eede00b7 100644 --- a/app/AGMSG_CORE_REF +++ b/app/AGMSG_CORE_REF @@ -1 +1 @@ -v1.1.6 +v1.1.7 diff --git a/app/package.json b/app/package.json index 254c0f7f..eec09523 100644 --- a/app/package.json +++ b/app/package.json @@ -1,7 +1,7 @@ { "name": "agmsg-app", "private": true, - "version": "0.1.4", + "version": "0.1.5", "type": "module", "scripts": { "dev": "vite", @@ -21,6 +21,7 @@ "@xterm/xterm": "^6.0.0", "i18next": "^26.3.4", "i18next-browser-languagedetector": "^8.2.1", + "lucide-react": "^1.23.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-i18next": "^17.0.8" diff --git a/app/pnpm-lock.yaml b/app/pnpm-lock.yaml index 075ad4b1..75058641 100644 --- a/app/pnpm-lock.yaml +++ b/app/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: i18next-browser-languagedetector: specifier: ^8.2.1 version: 8.2.1 + lucide-react: + specifier: ^1.23.0 + version: 1.23.0(react@19.2.7) react: specifier: ^19.1.0 version: 19.2.7 @@ -721,6 +724,11 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lucide-react@1.23.0: + resolution: {integrity: sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1480,6 +1488,10 @@ snapshots: dependencies: yallist: 3.1.1 + lucide-react@1.23.0(react@19.2.7): + dependencies: + react: 19.2.7 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 diff --git a/app/public/agmsg-mark.png b/app/public/agmsg-mark.png new file mode 100644 index 00000000..624b7e75 Binary files /dev/null and b/app/public/agmsg-mark.png differ diff --git a/app/scripts/sign-windows.ps1 b/app/scripts/sign-windows.ps1 new file mode 100644 index 00000000..286627ac --- /dev/null +++ b/app/scripts/sign-windows.ps1 @@ -0,0 +1,30 @@ +# Authenticode-signs one file with Azure Trusted Signing. Invoked by the Tauri +# bundler (bundle > windows > signCommand) once per artifact DURING the build: +# the bare app exe before it is packed into the MSI cab / NSIS installer, then +# the installers themselves. That ordering is the point — the updater's +# minisign .sig is generated after signing, over the bytes that actually ship +# (issue #333: post-build in-place signing invalidated the .sig and corrupted +# the MSI cabinet). +# +# Auth: DefaultAzureCredential. In CI, azure/login provides the Azure CLI +# credential via OIDC (federated for the main branch only — no client secret +# exists, which is why trusted-signing-cli is not usable here). +param( + [Parameter(Mandatory = $true)] + [string]$File +) + +$ErrorActionPreference = "Stop" + +if (-not (Get-Module -ListAvailable -Name TrustedSigning)) { + Install-Module -Name TrustedSigning -Force -Scope CurrentUser -Repository PSGallery +} + +Invoke-TrustedSigning ` + -Endpoint "https://eus.codesigning.azure.net/" ` + -CodeSigningAccountName "agmsg-artifact-signing" ` + -CertificateProfileName "agmsg-app-signing" ` + -FileDigest SHA256 ` + -TimestampRfc3161 "http://timestamp.acs.microsoft.com" ` + -TimestampDigest SHA256 ` + -Files $File diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 93aec21d..4c95c2dd 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "agmsg-app" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", "base64 0.22.1", @@ -26,6 +26,7 @@ dependencies = [ "tauri-plugin-process", "tauri-plugin-single-instance", "tauri-plugin-updater", + "tauri-plugin-window-state", "tempfile", ] @@ -4099,6 +4100,21 @@ dependencies = [ "zip", ] +[[package]] +name = "tauri-plugin-window-state" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704" +dependencies = [ + "bitflags 2.13.0", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-runtime" version = "2.11.3" diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 12bc1ea0..008d8d5d 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agmsg-app" -version = "0.1.4" +version = "0.1.5" description = "A Tauri App" authors = ["you"] edition = "2021" @@ -24,6 +24,7 @@ tauri-plugin-dialog = "2" tauri-plugin-updater = "2" tauri-plugin-process = "2" tauri-plugin-single-instance = "2" +tauri-plugin-window-state = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" # Terminal-embedded core: own agent PTYs (spawn + read + write + inject) and diff --git a/app/src-tauri/src/agent_state.rs b/app/src-tauri/src/agent_state.rs new file mode 100644 index 00000000..da21383b --- /dev/null +++ b/app/src-tauri/src/agent_state.rs @@ -0,0 +1,1167 @@ +use std::collections::VecDeque; +use std::time::{Duration, Instant}; + +use serde::Serialize; + +pub const TAIL_CAPACITY: usize = 8 * 1024; +pub const DETECTION_INTERVAL: std::time::Duration = std::time::Duration::from_millis(400); +const STARTUP_GRACE: Duration = Duration::from_secs(2); +const IDLE_CONFIRMATIONS: u8 = 3; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum PaneState { + Idle, + Working, + Blocked, + Unknown, +} + +pub struct DetectionTracker { + agent_type: String, + state: PaneState, + created_at: Instant, + idle_confirmations: u8, + last_tail: Option, +} + +impl DetectionTracker { + pub fn new(agent_type: String) -> Self { + Self { + agent_type, + state: PaneState::Unknown, + created_at: Instant::now(), + idle_confirmations: 0, + last_tail: None, + } + } + + pub fn state(&self) -> PaneState { + self.state + } + + pub fn observe(&mut self, tail: &str, now: Instant) -> Option { + if now.saturating_duration_since(self.created_at) < STARTUP_GRACE { + return None; + } + + // Compares the DERIVED text, not a raw byte/push counter: a blinking + // cursor or other zero-width escape noise still arrives as PTY bytes + // every tick even while the pane is genuinely idle, so a push-based + // "did anything arrive" signal never goes quiet and the 3-tick + // debounce below never got to fire (#385 — panes stuck showing + // Working forever after the agent actually finished). + let tail_changed = self.last_tail.as_deref() != Some(tail); + self.last_tail = Some(tail.to_string()); + let candidate = if tail_changed { + classify(&self.agent_type, tail) + } else { + // A static tail means nothing new happened since last tick: + // Working debounces down toward Idle below (no more output == + // probably done), and every other state — crucially including + // Idle itself — just holds. Re-running classify() here on an + // Idle pane's unchanged snapshot was the actual bug: if that + // frozen frame still had a stale spinner glyph in it (a + // synchronized-output redraw that stalled mid-animation), it + // matched Working again immediately, bounced right back to + // Idle after another 3-tick debounce, and repeated forever. + match self.state { + PaneState::Working => PaneState::Idle, + other => other, + } + }; + let next = match (self.state, candidate) { + (_, PaneState::Blocked) => { + self.idle_confirmations = 0; + PaneState::Blocked + } + (_, PaneState::Working) => { + self.idle_confirmations = 0; + PaneState::Working + } + (PaneState::Working, PaneState::Idle) => { + self.idle_confirmations = self.idle_confirmations.saturating_add(1); + if self.idle_confirmations < IDLE_CONFIRMATIONS { + PaneState::Working + } else { + self.idle_confirmations = 0; + PaneState::Idle + } + } + (_, next) => { + self.idle_confirmations = 0; + next + } + }; + + if next == self.state { + None + } else { + self.state = next; + Some(next) + } + } +} + +/// The last `n` NON-BLANK lines, in original order — herdr's +/// `bottom_non_empty_lines(n)`, used for grok's footer-hint dialogs below. +/// Unlike the rest of classify()'s substring matching (which runs against +/// the whole ~20-line window), these hints are only meaningful confined to +/// the very bottom of the screen — matching them anywhere in the wider +/// window risks tripping on ordinary scrollback that happens to mention +/// one of the same words (co1 review, #384/grok). +fn bottom_non_empty_lines<'a>(lines: &[&'a str], n: usize) -> Vec<&'a str> { + let mut result: Vec<&'a str> = lines + .iter() + .copied() + .filter(|line| !line.trim().is_empty()) + .collect(); + if result.len() > n { + result.drain(..result.len() - n); + } + result +} + +/// True for grok's Working status line: contains the "[stop]" chip +/// anywhere. Originally required a Braille spinner glyph at the START of +/// the SAME line too (matching herdr's grok.toml, which anchors on that +/// pairing since the startup splash also draws its logo out of Braille +/// characters — a bare glyph alone isn't safe). Dropped after a live +/// capture (#384/grok) showed why: grok's real "thinking" animation +/// redraws several overlapping spinner/counter elements in the same +/// screen region every tick, and our tail buffer is a linear byte stream, +/// not a real 2D screen grid — those redraws interleave into the same +/// captured text rather than each overwriting the last, so the glyph and +/// "[stop]" frequently land nowhere near each other or even on different +/// reconstructed "lines" despite being adjacent on screen. That line- +/// anchored check matched only ~10 of 124 ticks during a real "thinking" +/// stretch; "[stop]" alone, unanchored, matched consistently across long +/// runs in the same capture — the splash-only false positive it was +/// meant to prevent doesn't apply here since the splash never contains +/// "[stop]" at all, so requiring it is still safe on its own. +fn grok_working_line(line: &str) -> bool { + line.contains("[stop]") +} + +// grok's footer-hint dialogs need AND logic and bottom-two-line scoping +// that plain substring matching over the whole flattened tail can't +// express (co1 review, #384/grok) — TailBuffer::detection_tail evaluates +// them against the real, still-line-structured text (before flattening +// loses that structure) and bakes the result into these sentinels, which +// classify() then just treats as two more literal patterns like any other +// agent's. `\u{1}`-wrapped so they can never collide with real screen text. +// +// Covers two of grok's three known dialog shapes, confirmed against +// herdr's grok.toml (fork-herdr/src/detect/manifests/grok.toml) plus real +// Grok Build 0.2.82 observation. NOT covered: the option-select dialog +// (gutter + option key + ●/○ marker on one line, per herdr) — its exact +// on-screen format wasn't available to verify against real output, and +// guessing at it risks the same false-positive/negative class of bug this +// sentinel approach exists to avoid. +const GROK_PERMISSION_DIALOG_SENTINEL: &str = "\u{1}GROK_PERMISSION_DIALOG\u{1}"; +const GROK_QUESTION_DIALOG_SENTINEL: &str = "\u{1}GROK_QUESTION_DIALOG\u{1}"; +const GROK_WORKING_SENTINEL: &str = "\u{1}GROK_WORKING\u{1}"; + +pub fn classify(agent_type: &str, tail: &str) -> PaneState { + if !matches!( + agent_type, + "claude-code" | "claude" | "codex" | "gemini" | "grok" | "grok-build" + ) { + return PaneState::Unknown; + } + + const COMMON_BLOCKED: &[&str] = &[ + "Do you want to proceed?", + "Allow this action?", + "waiting for approval", + "Waiting for approval", + "Enter to confirm", + "(y/n)", + "[y/N]", + "[Y/n]", + // Generic numbered-choice menus (e.g. the plan-mode-exit conflict + // dialog) don't say "Do you want to proceed?" at all — confirmed + // from a live capture, #385 — but they all share this footer + // regardless of which menu is showing, so it covers the class + // instead of enumerating every prompt's own wording. + "Enter to select", + // Structural fallback for the SAME class of menu, for when even + // that footer isn't there (confirmed from a live capture, #385: the + // plan-review "Ready to code?" screen has neither "Do you want to + // proceed?" nor "Enter to select" — just "Would you like to + // proceed?" and a numbered list). Every one of these interactive + // menus opens with its first option pre-selected behind "❯", so + // matching that marker generalizes across prompt wording we + // haven't seen yet instead of enumerating each one — the same idea + // herdr uses for codex's "›" cursor glyph (src/detect/manifest.rs), + // just applied to claude's own selector character. + "❯ 1.", + ]; + const BRAILLE_SPINNERS: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + // Claude Code's "thinking" spinner cycles through these sparkle glyphs, + // not the braille dots above — confirmed from a real capture (#385): + // "✻i…", "✳…", "✶5", "✢di", "✽n30" all rendering behind a whimsical + // verb ("Considering…" etc). The braille set never matched claude panes, + // and "esc to interrupt" doesn't appear in the current CLI build either + // (checked via `strings` on the installed binary), so Working detection + // for claude was relying on neither signal actually firing — any tick + // without a Blocked match fell straight through to Idle mid-generation. + const CLAUDE_SPINNERS: &[&str] = &["✢", "✳", "✶", "✻", "✽"]; + // Verified against herdr's codex.toml (src/detect/manifests/codex.toml) + // rather than guessed: "Press enter to continue" and "Do you trust the + // contents of this directory?" were never real Codex CLI strings (the + // latter reads like VS Code's workspace-trust dialog, not Codex) and + // never matched anything. + const CODEX_BLOCKED: &[&str] = &[ + "Allow command?", + "enter to submit answer", + "enter to submit all", + "press enter to confirm or esc to cancel", + "[y/n]", + "yes (y)", + // Set in the window title, not the screen — TailBuffer::detection_tail + // appends the last-seen title to the flattened text, so this still + // matches via the same plain substring check. herdr's cheapest/ + // highest-priority Codex blocked signal. + "Action Required", + ]; + const GEMINI_BLOCKED: &[&str] = &[ + "Do you trust the files in this folder?", + "(Use Enter to select)", + ]; + + const GROK_BLOCKED: &[&str] = &[ + GROK_PERMISSION_DIALOG_SENTINEL, + GROK_QUESTION_DIALOG_SENTINEL, + ]; + const GROK_WORKING: &[&str] = &[GROK_WORKING_SENTINEL]; + + let blocked_patterns = match agent_type { + "codex" => CODEX_BLOCKED, + "gemini" => GEMINI_BLOCKED, + "grok" | "grok-build" => GROK_BLOCKED, + _ => &[], + }; + let working_patterns: &[&str] = match agent_type { + "gemini" => &["Thinking", "esc to cancel"], + // Codex's working signal is purely the title-bar spinner glyph + // (below) — herdr's codex.toml has no phrase-based working rule at + // all, and "esc to interrupt" is confirmed absent from it (it only + // appears in devin.toml/opencode.toml). + "codex" => &[], + "grok" | "grok-build" => GROK_WORKING, + _ => &["esc to interrupt", "Esc to interrupt"], + }; + let spinners: &[&str] = match agent_type { + "claude" | "claude-code" => CLAUDE_SPINNERS, + // No bare braille here on purpose — see GROK_WORKING_SENTINEL's + // doc: the startup splash draws its logo out of Braille characters + // too, so BRAILLE_SPINNERS alone would misclassify it as Working. + "grok" | "grok-build" => &[], + _ => BRAILLE_SPINNERS, + }; + + // Case-insensitive, matching herdr's own gates (they normalize both + // sides via `.to_lowercase()`) — we don't actually know the exact + // casing every one of these strings renders with on screen, so + // matching loosely here beats silently missing a real match over a + // capitalization difference. + let tail_lower = tail.to_lowercase(); + if COMMON_BLOCKED + .iter() + .chain(blocked_patterns.iter()) + .any(|pattern| tail_lower.contains(&pattern.to_lowercase())) + { + PaneState::Blocked + } else if working_patterns + .iter() + .chain(spinners.iter()) + .any(|pattern| tail_lower.contains(&pattern.to_lowercase())) + { + PaneState::Working + } else { + PaneState::Idle + } +} + +pub struct TailBuffer { + bytes: VecDeque, +} + +impl Default for TailBuffer { + fn default() -> Self { + Self { + bytes: VecDeque::with_capacity(TAIL_CAPACITY), + } + } +} + +impl TailBuffer { + pub fn push(&mut self, input: &[u8]) { + if input.is_empty() { + return; + } + let overflow = self + .bytes + .len() + .saturating_add(input.len()) + .saturating_sub(TAIL_CAPACITY); + self.bytes.drain(..overflow.min(self.bytes.len())); + if input.len() > TAIL_CAPACITY { + self.bytes.extend(&input[input.len() - TAIL_CAPACITY..]); + } else { + self.bytes.extend(input); + } + } + + pub fn detection_tail(&self) -> String { + let raw: Vec = self.bytes.iter().copied().collect(); + let (text, title) = strip_ansi(&String::from_utf8_lossy(&raw)); + // Split on '\r' as well as '\n': ink redraws the spinner/status line + // in place with a bare carriage return, not a newline (confirmed + // from a real capture, #385 — dozens of "Cerebrating…" spinner + // frames arrive '\r'-separated with no '\n' at all). `.lines()` + // alone treated that whole run as ONE line, so it never aged out of + // the last-20 window — a resolved permission prompt, or a finished + // "Working" spinner, could keep matching indefinitely because its + // bytes were still sitting near the front of that one giant "line" + // long after the real terminal had moved past them. + let lines: Vec<&str> = text.split(['\r', '\n']).collect(); + let recent = &lines[lines.len().saturating_sub(20)..]; + + // grok evaluated here, against real lines, before the flattening + // below loses that structure — see the GROK_*_SENTINEL consts' + // doc. herdr scopes grok's own rules to bottom_non_empty_lines(2) + // of the whole recent screen (not the box-region scoping below, + // which is a claude-specific concept), so this runs against + // `recent` directly rather than `scoped`. + let grok_footer = bottom_non_empty_lines(recent, 2).join(" ").to_lowercase(); + // "ctrl+o:always-approve" — confirmed from a live capture, #384/ + // grok: the real footer says "always-approve", not "yolo" as + // first assumed from herdr's notes. That earlier wrong string + // meant this AND-condition could never fire at all. + let grok_permission_dialog = grok_footer.contains(":select") + && grok_footer.contains("ctrl+o:always-approve") + && grok_footer.contains("ctrl+c:cancel"); + let grok_question_dialog = + grok_footer.contains("tab:scrollback") && grok_footer.contains("shift+x:dismiss"); + let grok_working = recent.iter().any(|line| grok_working_line(line)); + + // Structural narrowing, ported from herdr's src/detect/manifest.rs + // (prompt_box_body / after_last_horizontal_rule): scope down to the + // live bordered box's body when the window holds a complete one, + // else to whatever came after the most recent box's closing rule. + // Pure box-drawing-character geometry, no words involved, so it + // generalizes across every dialog's own wording and sheds stale + // scrollback the recency window alone can't (a resolved dialog's + // closing rule marks everything above it as no-longer-current). + let scoped = prompt_box_body(recent).unwrap_or_else(|| after_last_horizontal_rule(recent)); + let menu_option_selected = scoped.iter().any(|line| is_chevron_menu_line(line)); + + // Joined with a space, not '\n'/'\r': a narrow pane wraps a prompt + // like "Do you want to proceed?" across two frames, and either + // separator would split that phrase apart, permanently defeating + // classify()'s substring match for as long as the pane stays that + // width. + let mut flattened = scoped.join(" "); + if menu_option_selected { + // A line starting with claude's selector glyph followed by real + // content (not the bare, nothing-highlighted composer prompt) + // means SOME option is currently highlighted in an interactive + // menu — herdr: claude.toml's `^\s*❯` gate, the same idea as + // codex's `›` cursor glyph. Guaranteed present in the flattened + // text this way regardless of which option number is shown or + // whether an adjacent color code broke the natural adjacency. + flattened.push_str(" ❯ 1."); + } + // Codex reports its own state through the window title, not the + // screen (herdr: codex.toml's `osc_title` region — a Braille + // spinner glyph means Working, "Action Required" means Blocked). + // Appending it here lets classify()'s existing substring patterns + // pick it up without needing their own code path. + if let Some(title) = title { + flattened.push(' '); + flattened.push_str(&title); + } + if grok_permission_dialog { + flattened.push(' '); + flattened.push_str(GROK_PERMISSION_DIALOG_SENTINEL); + } + if grok_question_dialog { + flattened.push(' '); + flattened.push_str(GROK_QUESTION_DIALOG_SENTINEL); + } + if grok_working { + flattened.push(' '); + flattened.push_str(GROK_WORKING_SENTINEL); + } + flattened + } +} + +// Structural detectors ported from herdr's src/detect/manifest.rs — pure +// box-drawing-character and glyph geometry, no literal English words. herdr +// uses these to pick WHICH region of the screen to run its phrase rules +// against, not to classify state by shape alone; we do the same, applying +// them ahead of classify()'s (still literal) pattern matching. + +/// A line made (almost) entirely of the box-drawing rule character, with +/// nothing but whitespace after it — the top/bottom border of a bordered +/// box (the permission-prompt/plan-review dialogs all draw one). +fn is_horizontal_rule(line: &str) -> bool { + let trimmed = line.trim(); + if trimmed.is_empty() { + return false; + } + let rule_chars = trimmed.chars().take_while(|&ch| ch == '─').count(); + if rule_chars == 0 { + return false; + } + let rest: String = trimmed.chars().skip(rule_chars).collect(); + rest.trim().is_empty() || rule_chars >= 3 +} + +/// Index of the top border of the most recently opened box: the +/// second-to-last horizontal-rule line, scanning bottom-up. +fn prompt_box_top_border_index(lines: &[&str]) -> Option { + let mut rules_seen = 0; + for index in (0..lines.len()).rev() { + if is_horizontal_rule(lines[index]) { + rules_seen += 1; + if rules_seen == 2 { + return Some(index); + } + } + } + None +} + +/// The lines strictly between a currently-open box's top and bottom +/// border, if the window holds a complete one. +fn prompt_box_body<'a>(lines: &'a [&'a str]) -> Option<&'a [&'a str]> { + let top = prompt_box_top_border_index(lines)?; + let body_start = top + 1; + let body_end = lines[body_start..] + .iter() + .position(|line| is_horizontal_rule(line)) + .map(|relative| body_start + relative) + .unwrap_or(lines.len()); + lines.get(body_start..body_end) +} + +/// Everything after the last horizontal-rule line in the window (the +/// fallback when there's no complete box, only a box that just closed). +fn after_last_horizontal_rule<'a>(lines: &'a [&'a str]) -> &'a [&'a str] { + match lines.iter().rposition(|line| is_horizontal_rule(line)) { + Some(index) => &lines[index + 1..], + None => lines, + } +} + +/// claude's menu-selector glyph followed by a NUMBERED option ("❯ 1. ...") +/// means an option is currently highlighted in an interactive menu (herdr: +/// claude.toml's `^\s*❯` gate). The bare composer prompt ("❯" alone) is +/// already excluded by requiring real content — but the composer ALSO +/// shows "❯ " immediately followed by whatever the user is typing (e.g. +/// "❯ fix the bug"), which looks just as "non-empty" and caused a false +/// Blocked the moment anyone typed a message (#385, live repro). Requiring +/// the content to start with digits + '.' (the option-numbering every one +/// of these menus actually uses) rules that out without misclassifying a +/// real menu. +fn is_chevron_menu_line(line: &str) -> bool { + let Some(rest) = line.trim_start().strip_prefix('❯') else { + return false; + }; + let rest = rest.trim_start(); + let digits = rest.chars().take_while(|c| c.is_ascii_digit()).count(); + digits > 0 && rest.as_bytes().get(digits) == Some(&b'.') +} + +// Cursor Forward (CSI n C) and Cursor Horizontal Absolute (CSI n G) both move +// the cursor without printing — ink pads/aligns text with them instead of +// writing literal spaces (confirmed from a real permission-prompt capture, +// #385: "Do\x1b[5Gyou\x1b[9Gwant\x1b[14Gto\x1b[17Gproceed?" renders as "Do you +// want to proceed?", but naively dropping the escapes glued it into +// "Doyouwanttoproceed?", which no longer matched classify()'s substring +// patterns — the actual cause of panes getting stuck instead of turning +// Blocked/Working). `col` tracks the 0-indexed column the next printed +// character would land on, reset on '\r'/'\n', so both forms can be +// rendered back as the gap of spaces they visually are. +// +// Returns the stripped body text plus the LAST OSC 0/2 window-title string +// seen, if any. Codex sets its live/blocked state in the window title, not +// the visible screen (herdr: codex.toml's `osc_title` region) — e.g. a +// Braille spinner glyph in the title means Working, "Action Required" means +// Blocked — so that title text has to survive somewhere for classify() to +// see it, instead of being silently discarded like every other OSC. +fn strip_ansi(input: &str) -> (String, Option) { + let mut output = String::with_capacity(input.len()); + let mut title: Option = None; + let mut col: usize = 0; + // Tracks a VIRTUAL row, not a real terminal grid — there's no screen + // buffer here, just a linear byte stream. Incremented on '\n' and on a + // forward CSI H/f jump (below); a backward jump is left alone, same + // rationale as a backward 'G' — already-emitted text can't be + // un-printed, so there's nothing correct to insert for it either. + let mut row: usize = 0; + let mut chars = input.chars().peekable(); + while let Some(ch) = chars.next() { + if ch != '\u{1b}' { + if ch == '\n' { + row += 1; + col = 0; + } else if ch == '\r' { + col = 0; + } else { + col += 1; + } + output.push(ch); + continue; + } + + match chars.peek().copied() { + Some('[') => { + chars.next(); + let mut params = String::new(); + for c in chars.by_ref() { + if ('@'..='~').contains(&c) { + match c { + 'C' => { + let n = params.parse().unwrap_or(1).clamp(1, 512); + output.extend(std::iter::repeat_n(' ', n)); + col += n; + } + // Only a forward jump can be represented as + // spaces; a same-or-backward jump is left alone + // since already-emitted text can't be un-printed. + 'G' => { + let target: usize = params.parse().unwrap_or(1).max(1); + if target > col + 1 { + let gap = (target - 1 - col).min(512); + output.extend(std::iter::repeat_n(' ', gap)); + col = target - 1; + } + } + // Cursor Position ("row;colH", 'f' is the VT100 + // alias) — grok's multi-panel layout jumps + // between screen regions with this, not just + // within-row 'G' (confirmed from a live + // capture, #384/grok: distinct panel text was + // running together with no separator at all — + // "response… 0.0s0.0s [stop]ccancl" was really + // three separate lines/regions). ANY row change + // — forward OR backward — gets a single + // separator newline (co1 review: an earlier + // version only handled forward jumps, so an + // animation that repeatedly redraws the same + // upper panel — jump down, jump back up, jump + // down again — glued that panel's redraws back + // together after the first round-trip; there's + // no real screen buffer to overwrite here + // either way, so one newline per distinct row + // is the closest honest representation + // regardless of direction or distance). The + // column portion then reuses 'G's logic. + 'H' | 'f' => { + let mut parts = params.splitn(2, ';'); + let target_row: usize = parts + .next() + .and_then(|p| p.parse().ok()) + .unwrap_or(1) + .max(1); + let target_col: usize = parts + .next() + .and_then(|p| p.parse().ok()) + .unwrap_or(1) + .max(1); + let target_row0 = target_row - 1; + if target_row0 != row { + output.push('\n'); + row = target_row0; + col = 0; + } + if target_col > col + 1 { + let gap = (target_col - 1 - col).min(512); + output.extend(std::iter::repeat_n(' ', gap)); + col = target_col - 1; + } + } + _ => {} + } + break; + } + params.push(c); + } + } + Some(']') => { + chars.next(); + let mut body = String::new(); + let mut escaped = false; + for c in chars.by_ref() { + if c == '\u{7}' || (escaped && c == '\\') { + break; + } + escaped = c == '\u{1b}'; + body.push(c); + } + // "0;" or "2;<title>" (OSC 0 sets icon+title, OSC 2 + // sets title only — both are the window title as far as a + // reader is concerned). + if let Some(rest) = body.strip_prefix("0;").or_else(|| body.strip_prefix("2;")) { + title = Some(rest.trim_end_matches('\u{1b}').to_string()); + } + } + _ => {} + } + } + (output, title) +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, Instant}; + + use super::{ + after_last_horizontal_rule, bottom_non_empty_lines, classify, grok_working_line, + is_chevron_menu_line, is_horizontal_rule, prompt_box_body, DetectionTracker, PaneState, + TailBuffer, TAIL_CAPACITY, + }; + + #[test] + fn tail_is_bounded_to_capacity() { + let mut tail = TailBuffer::default(); + tail.push(&vec![b'a'; TAIL_CAPACITY + 10]); + assert_eq!(tail.bytes.len(), TAIL_CAPACITY); + } + + #[test] + fn detection_tail_strips_ansi_and_keeps_last_twenty_lines() { + let mut tail = TailBuffer::default(); + let content = (0..25) + .map(|line| format!("\u{1b}[31mline-{line}\u{1b}[0m")) + .collect::<Vec<_>>() + .join("\n"); + tail.push(content.as_bytes()); + let snapshot = tail.detection_tail(); + assert!(!snapshot.contains('\u{1b}')); + assert!(snapshot.starts_with("line-5")); + assert!(snapshot.ends_with("line-24")); + } + + #[test] + fn carriage_return_redraws_age_out_old_frames_like_newlines_do() { + // ink rewrites the spinner in place with a bare '\r', never '\n' + // (#385). A resolved permission prompt followed by enough spinner + // redraws must age out of the last-20-frame window exactly like a + // resolved prompt followed by enough real newlines would — a stale + // "Do you want to proceed?" sitting near the front of one giant + // '\r'-joined "line" was why panes stayed stuck as Blocked (or + // Working, from stale spinner text) long after they'd moved on. + let mut tail = TailBuffer::default(); + tail.push(b"Do you want to proceed?\r"); + for i in 0..25 { + tail.push(format!("frame-{i}\r").as_bytes()); + } + let snapshot = tail.detection_tail(); + assert!( + !snapshot.contains("Do you want to proceed?"), + "got: {snapshot:?}" + ); + assert_eq!(classify("claude", &snapshot), PaneState::Idle); + } + + #[test] + fn is_horizontal_rule_requires_only_whitespace_after_a_short_run() { + assert!(is_horizontal_rule("────────────")); + assert!(is_horizontal_rule(" ─── ")); + // A pure rule of any length (nothing after it) still counts. + assert!(is_horizontal_rule("──")); + // With trailing content, at least 3 rule chars are required — a + // labeled divider like "── Section ──" is still a rule, but a + // stray "--" in normal prose isn't. + assert!(is_horizontal_rule("─── Section")); + assert!(!is_horizontal_rule("── not a rule")); + assert!(!is_horizontal_rule("")); + assert!(!is_horizontal_rule("plain text")); + } + + #[test] + fn is_chevron_menu_line_excludes_the_bare_composer_prompt() { + assert!(is_chevron_menu_line("❯ 1. Yes")); + assert!(is_chevron_menu_line(" ❯ 1. Exit plan mode")); + assert!(is_chevron_menu_line("❯ 10. tenth option")); + assert!(!is_chevron_menu_line("❯")); + assert!(!is_chevron_menu_line("❯ ")); + assert!(!is_chevron_menu_line("no chevron here")); + } + + #[test] + fn is_chevron_menu_line_ignores_the_user_typing_a_message() { + // Live repro (#385): the composer shows "❯ <whatever you're + // typing>" the same way it shows "❯ 1. <option>" for a real menu — + // "content after the glyph" alone isn't enough to tell them apart. + // Typing any message briefly flipped the pane to Blocked until this + // was tightened to require the option-numbering real menus use. + assert!(!is_chevron_menu_line("❯ あああ")); + assert!(!is_chevron_menu_line("❯ /agmsg actas Alice")); + assert!(!is_chevron_menu_line("❯ fix the lint errors")); + } + + #[test] + fn prompt_box_body_extracts_the_lines_between_the_two_most_recent_rules() { + let lines = [ + "stale text", + "────", + "Do you want to proceed?", + "❯ 1. Yes", + "────", + ]; + assert_eq!( + prompt_box_body(&lines), + Some(&["Do you want to proceed?", "❯ 1. Yes"][..]) + ); + } + + #[test] + fn after_last_horizontal_rule_falls_back_when_only_one_rule_is_in_view() { + let lines = [ + "Do you want to proceed?", + "❯ 1. Yes", + "────", + "next turn's output", + ]; + assert_eq!(prompt_box_body(&lines), None); + assert_eq!(after_last_horizontal_rule(&lines), &["next turn's output"]); + } + + #[test] + fn detection_tail_scopes_to_the_open_box_and_drops_a_stale_closed_one() { + // Two dialogs back to back: an earlier, already-resolved box (whose + // "Do you want to proceed?" is stale scrollback now) followed by a + // brand new one that's actually open. Only the open box's content + // should survive — this is what makes a resolved dialog's text stop + // poisoning classify() even within the same 20-line window, on top + // of (not instead of) the recency window itself (#385). + let mut tail = TailBuffer::default(); + tail.push( + "────\n\ +Do you want to proceed?\n\ +❯ 1. Yes\n\ +────\n\ +some output after answering\n\ +────\n\ +Would you like to proceed?\n\ +❯ 1. Yes, and use auto mode\n\ +────\n" + .as_bytes(), + ); + let snapshot = tail.detection_tail(); + assert!( + !snapshot.contains("Do you want to proceed?"), + "got: {snapshot:?}" + ); + assert!(snapshot.contains("Would you like to proceed?")); + assert_eq!(classify("claude", &snapshot), PaneState::Blocked); + } + + #[test] + fn detection_tail_strips_non_title_bel_and_st_terminated_osc_sequences() { + let mut tail = TailBuffer::default(); + tail.push(b"before\x1b]9;progress\x07middle\x1b]8;;url\x1b\\after"); + assert_eq!(tail.detection_tail(), "beforemiddleafter"); + } + + #[test] + fn detection_tail_appends_the_osc_0_and_2_window_title() { + // Codex reports Working/Blocked through the window title, not the + // screen (herdr: codex.toml's `osc_title` region) — the title has + // to survive into the flattened text for classify() to see it. + let mut tail = TailBuffer::default(); + tail.push(b"before\x1b]0;Action Required\x07after"); + assert_eq!(tail.detection_tail(), "beforeafter Action Required"); + + // OSC 2 (title-only, no icon) counts the same way. + let mut tail2 = TailBuffer::default(); + tail2.push(b"before\x1b]2;codex\x07after"); + assert_eq!(tail2.detection_tail(), "beforeafter codex"); + } + + #[test] + fn codex_blocked_and_working_signals_come_from_the_title_not_the_screen() { + // End-to-end (TailBuffer -> classify()): a codex pane whose visible + // screen has nothing recognizable, but whose title carries the + // real signal (herdr: codex.toml's osc_title region). + let mut blocked = TailBuffer::default(); + blocked.push(b"ordinary transcript output\x1b]0;Action Required\x07"); + assert_eq!( + classify("codex", &blocked.detection_tail()), + PaneState::Blocked + ); + + let mut working = TailBuffer::default(); + working.push("ordinary transcript output\x1b]2;⠙ codex\u{7}".as_bytes()); + assert_eq!( + classify("codex", &working.detection_tail()), + PaneState::Working + ); + } + + #[test] + fn cursor_forward_sequences_become_the_spaces_they_visually_are() { + // CSI n C (Cursor Forward) moves the cursor without printing — used + // here for the box's 1-column left indent before "Do". + let mut tail = TailBuffer::default(); + tail.push(b"\x1b[1CDo you want to proceed?"); + assert_eq!(tail.detection_tail(), " Do you want to proceed?"); + } + + #[test] + fn cursor_horizontal_absolute_sequences_become_the_spaces_they_visually_are() { + // Captured verbatim (mid-word color codes trimmed) from a real + // Claude Code permission dialog (#385): ink right-pads each word to + // a precomputed column with CSI n G (Cursor Horizontal Absolute) + // instead of writing literal spaces. Dropping those escapes (the + // old behavior, which only handled the unrelated Cursor Forward + // form) glued the phrase into "Doyouwanttoproceed?", which no + // longer matched classify()'s "Do you want to proceed?" pattern — + // the actual cause of panes getting stuck instead of turning + // Blocked. + let mut tail = TailBuffer::default(); + tail.push(b"\x1b[1CDo\x1b[5Gyou\x1b[9Gwant\x1b[14Gto\x1b[17Gproceed?"); + assert_eq!(tail.detection_tail(), " Do you want to proceed?"); + assert_eq!( + classify("claude", &tail.detection_tail()), + PaneState::Blocked + ); + } + + #[test] + fn cursor_position_jumps_to_a_new_row_become_a_newline() { + // grok's multi-panel TUI writes separate screen regions with CSI + // "row;colH" (Cursor Position), not just within-row 'G' — dropping + // that (the old behavior) glued unrelated panel text together with + // no separator at all (confirmed from a live capture, #384/grok: + // "response… 0.0s0.0s [stop]ccancl" was really three distinct + // lines run together). A forward row jump now becomes a newline. + let mut tail = TailBuffer::default(); + tail.push(b"line one\x1b[2;1Hline two\x1b[3;5Hline three"); + assert_eq!(tail.detection_tail(), "line one line two line three"); + + // Same row, different column — still just a same-row space gap, + // same as plain 'G' (an absolute row;col jump where the row part + // happens to be unchanged). + let mut same_row = TailBuffer::default(); + same_row.push(b"ab\x1b[1;5Hcd"); + assert_eq!(same_row.detection_tail(), "ab cd"); + } + + #[test] + fn cursor_position_backward_row_jumps_also_get_a_newline() { + // co1 review, PR #395: an earlier version only inserted a newline + // for FORWARD row jumps, leaving `row` stale on a backward one — + // grok's real "thinking" animation jumps back up to redraw an + // earlier panel constantly, and that stale `row` meant the + // separator silently stopped firing after the first round-trip + // ("response… [stop]ccancl"-style gluing). Minimal repro from the + // review: without this fix, "a\x1b[3;1Hb\x1b[1;1Hc" glues "bc". + let mut tail = TailBuffer::default(); + tail.push(b"a\x1b[3;1Hb\x1b[1;1Hc"); + assert_eq!(tail.detection_tail(), "a b c"); + } + + #[test] + fn cursor_position_survives_a_panel_round_trip() { + // Two "panels" (rows) redrawn alternately across several jumps — + // down, down, up, down — the shape of grok's real thinking + // animation repeatedly updating two status lines. Every redraw + // must land as its own separated chunk regardless of direction. + let mut tail = TailBuffer::default(); + tail.push(b"\x1b[1;1Hx1\x1b[2;1Hy1\x1b[1;1Hx2\x1b[2;1Hy2"); + assert_eq!(tail.detection_tail(), "x1 y1 x2 y2"); + } + + #[test] + fn detection_tail_survives_narrow_pane_word_wrap() { + let mut tail = TailBuffer::default(); + // A narrow pane wraps the approval prompt mid-phrase. + tail.push(b"Do you want to\nproceed?\n"); + assert_eq!( + classify("claude", &tail.detection_tail()), + PaneState::Blocked + ); + } + + #[test] + fn blocked_patterns_take_priority_over_working_noise() { + assert_eq!( + classify("codex", "Thinking\nAllow command?"), + PaneState::Blocked + ); + } + + #[test] + fn uses_agent_specific_blocked_patterns() { + assert_eq!( + classify("gemini", "Do you trust the files in this folder?"), + PaneState::Blocked + ); + // "enter to submit ANSWER", not COMMON_BLOCKED's "Enter to confirm" + // — picked to not accidentally overlap with the shared list, so + // this only passes if it's really CODEX_BLOCKED doing the work. + assert_eq!( + classify("codex", "please press Enter to submit Answer now"), + PaneState::Blocked + ); + assert_eq!( + classify("claude", "please press Enter to submit Answer now"), + PaneState::Idle + ); + } + + #[test] + fn uses_claude_sparkle_spinner_not_braille() { + // Real captured spinner frames (#385) — claude never emits the + // braille dots, so this only passes once classify() checks the + // sparkle glyphs for claude specifically. + assert_eq!( + classify("claude", "✻ Considering… (10s)"), + PaneState::Working + ); + assert_eq!( + classify("claude-code", "✳ Percolating…"), + PaneState::Working + ); + assert_eq!( + classify("claude", "⠋ some other cli's spinner"), + PaneState::Idle + ); + } + + #[test] + fn ignores_claude_dashboard_history_headings() { + assert_eq!( + classify("claude", "Working\nCompleted\n3 awaiting input"), + PaneState::Idle + ); + } + + #[test] + fn detects_generic_numbered_choice_menus() { + // Captured live (#385): the plan-mode-exit conflict dialog never + // says "Do you want to proceed?" — only a numbered list and this + // footer. + assert_eq!( + classify( + "claude", + "1. Exit plan mode and continue actas 2. Stay in plan mode Enter to select · ↑/↓ to navigate · Esc to cancel" + ), + PaneState::Blocked + ); + } + + #[test] + fn detects_menus_with_neither_known_phrase_via_the_selector_marker() { + // Captured live (#385): the plan-review "Ready to code?" screen has + // neither "Do you want to proceed?" nor "Enter to select" — just + // "Would you like to proceed?", which isn't in any pattern list — + // so only the "❯ 1." selector marker catches it. + assert_eq!( + classify( + "claude", + "Would you like to proceed? ❯ 1. Yes, and use auto mode 2. Yes, manually approve edits" + ), + PaneState::Blocked + ); + } + + #[test] + fn unsupported_agents_remain_unknown() { + assert_eq!(classify("devin", "Thinking"), PaneState::Unknown); + } + + #[test] + fn bottom_non_empty_lines_skips_blanks_wherever_they_fall() { + let lines = ["a", "b", "", "c", "", ""]; + assert_eq!(bottom_non_empty_lines(&lines, 2), vec!["b", "c"]); + // Fewer real lines than requested — returns what's there. + assert_eq!(bottom_non_empty_lines(&["only"], 2), vec!["only"]); + assert_eq!(bottom_non_empty_lines(&[], 2), Vec::<&str>::new()); + } + + #[test] + fn grok_working_line_detects_the_stop_chip_anywhere_in_the_line() { + assert!(grok_working_line( + "⠋ Waiting on subagent... 2.8s 13s [stop]" + )); + assert!(grok_working_line("some line ending in [stop]")); + // A bare spinner glyph with no "[stop]" chip at all — e.g. the + // startup splash logo, which is drawn out of Braille characters + // too — still doesn't match on its own. + assert!(!grok_working_line("⠋ agmsg logo splash art")); + } + + #[test] + fn grok_detects_working_status_line_via_a_real_capture() { + let mut tail = TailBuffer::default(); + tail.push("some earlier output\n⠋ Waiting on subagent... 2.8s 13s [stop]\n".as_bytes()); + assert_eq!(classify("grok", &tail.detection_tail()), PaneState::Working); + } + + #[test] + fn grok_ignores_a_startup_splash_logo_line_starting_with_braille() { + // A logo art line that legitimately STARTS with a Braille glyph + // (as real dot-matrix ASCII art does), same as a working status + // line would — but with no "[stop]" chip, since it's just artwork. + let mut tail = TailBuffer::default(); + tail.push( + "\u{2807}\u{2807}\u{2807} GROK BUILD \u{2807}\u{2807}\u{2807}\nready\n".as_bytes(), + ); + assert_eq!(classify("grok", &tail.detection_tail()), PaneState::Idle); + } + + #[test] + fn grok_requires_all_three_permission_hints_on_the_bottom_two_lines() { + // All three, present — Blocked. Footer text is verbatim from a + // real capture (#384/grok): "ctrl+o:yolo" was the original + // (wrong) guess — the actual CLI says "always-approve". + let mut blocked = TailBuffer::default(); + blocked.push(b"1 (\xe2\x97\x8f) Yes, and don't ask again\n1/3:select | Ctrl+o:always-approve | Ctrl+c:cancel\n"); + assert_eq!( + classify("grok", &blocked.detection_tail()), + PaneState::Blocked + ); + + // Only one of the three — a real permission dialog needs all of + // them, so a lone mention (e.g. in ordinary help text) must NOT + // trigger Blocked (co1 review: this was the actual false-positive + // risk with a flat OR-list of single fragments). + let mut partial = TailBuffer::default(); + partial.push(b"press Ctrl+o:always-approve to skip confirmations\nready\n"); + assert_eq!(classify("grok", &partial.detection_tail()), PaneState::Idle); + } + + #[test] + fn grok_permission_hints_outside_the_bottom_two_lines_do_not_block() { + // The three hints appear, but scrolled up out of the bottom two + // lines by later, unrelated output — must not still read as + // Blocked (co1 review: herdr scopes this to bottom_non_empty_lines + // (2), not the whole visible window). + let mut tail = TailBuffer::default(); + tail.push(b"1/3:select | Ctrl+o:always-approve | Ctrl+c:cancel\nsome later normal output\nready\n"); + assert_eq!(classify("grok", &tail.detection_tail()), PaneState::Idle); + } + + #[test] + fn grok_detects_the_question_dialog_footer() { + let mut tail = TailBuffer::default(); + tail.push(b"What should I call the new file?\ntab:scrollback shift+x:dismiss\n"); + assert_eq!(classify("grok", &tail.detection_tail()), PaneState::Blocked); + } + + #[test] + fn grok_build_agent_type_is_recognized_the_same_as_grok() { + let mut tail = TailBuffer::default(); + tail.push("⠋ Waiting on subagent... [stop]\n".as_bytes()); + assert_eq!( + classify("grok-build", &tail.detection_tail()), + PaneState::Working + ); + } + + #[test] + fn working_to_idle_requires_three_confirmations() { + let started = Instant::now(); + let mut tracker = DetectionTracker::new("claude".to_string()); + let ready = started + Duration::from_secs(3); + assert_eq!( + tracker.observe("esc to interrupt", ready), + Some(PaneState::Working) + ); + assert_eq!(tracker.observe("esc to interrupt", ready), None); + assert_eq!(tracker.observe("esc to interrupt", ready), None); + assert_eq!( + tracker.observe("esc to interrupt", ready), + Some(PaneState::Idle) + ); + } + + #[test] + fn blocked_state_stays_sticky_while_output_is_quiet() { + let started = Instant::now(); + let mut tracker = DetectionTracker::new("codex".to_string()); + let ready = started + Duration::from_secs(3); + assert_eq!( + tracker.observe("Allow command?", ready), + Some(PaneState::Blocked) + ); + assert_eq!(tracker.observe("Allow command?", ready), None); + assert_eq!(tracker.state(), PaneState::Blocked); + } + + #[test] + fn startup_grace_keeps_new_panes_unknown() { + let started = Instant::now(); + let mut tracker = DetectionTracker::new("gemini".to_string()); + assert_eq!(tracker.observe("Thinking", started), None); + assert_eq!(tracker.state(), PaneState::Unknown); + } + + #[test] + fn changing_text_keeps_resetting_the_idle_debounce() { + // A live token/elapsed-time counter changes the tail every tick + // while genuinely still working (e.g. "esc to interrupt (12s)" -> + // "... (13s)"). Each such change must keep resetting the 3-tick + // debounce, the same way a truly static byte stream would — the + // debounce keys off the derived text, not a raw "did any PTY byte + // arrive" counter, which could never go quiet on its own once a + // zero-width escape (e.g. cursor blink) starts firing every tick + // regardless of real activity (#385). + let started = Instant::now(); + let mut tracker = DetectionTracker::new("claude".to_string()); + let ready = started + Duration::from_secs(3); + assert_eq!( + tracker.observe("esc to interrupt (1s)", ready), + Some(PaneState::Working) + ); + assert_eq!(tracker.observe("esc to interrupt (2s)", ready), None); + assert_eq!(tracker.observe("esc to interrupt (3s)", ready), None); + // Still changing on what would have been the 3rd quiet tick — stays + // Working, doesn't debounce yet. + assert_eq!(tracker.observe("esc to interrupt (4s)", ready), None); + assert_eq!(tracker.state(), PaneState::Working); + // Now it goes genuinely quiet — 3 fresh identical ticks required. + assert_eq!(tracker.observe("esc to interrupt (4s)", ready), None); + assert_eq!(tracker.observe("esc to interrupt (4s)", ready), None); + assert_eq!( + tracker.observe("esc to interrupt (4s)", ready), + Some(PaneState::Idle) + ); + } + + #[test] + fn idle_does_not_bounce_back_to_working_on_a_stale_frozen_spinner() { + // Captured live from a real pane (#385): a synchronized-output + // redraw stalled mid-animation, so the tail stayed byte-for-byte + // identical for many ticks in a row while still containing a + // spinner glyph from the moment it froze. Once Working correctly + // debounces down to Idle, re-running classify() on that same + // still-spinner-containing static tail flipped it right back to + // Working — which then re-debounced to Idle after another 3 ticks, + // forever. A static tail must hold whatever state it's already in + // (other than Working, which debounces toward Idle) rather than + // being reclassified from scratch. + let started = Instant::now(); + let mut tracker = DetectionTracker::new("claude".to_string()); + let ready = started + Duration::from_secs(3); + let frozen = "✻ Baked for 50s · 1 monitor still running"; + assert_eq!(tracker.observe(frozen, ready), Some(PaneState::Working)); + assert_eq!(tracker.observe(frozen, ready), None); + assert_eq!(tracker.observe(frozen, ready), None); + assert_eq!(tracker.observe(frozen, ready), Some(PaneState::Idle)); + // The bug: this next call used to flip straight back to Working. + for _ in 0..10 { + assert_eq!(tracker.observe(frozen, ready), None); + assert_eq!(tracker.state(), PaneState::Idle); + } + } +} diff --git a/app/src-tauri/src/agmsg.rs b/app/src-tauri/src/agmsg.rs index 00ee570b..bf9f3ff7 100644 --- a/app/src-tauri/src/agmsg.rs +++ b/app/src-tauri/src/agmsg.rs @@ -437,6 +437,14 @@ pub fn agmsg_install(app: AppHandle) -> Result<(), String> { /// running app can compare against it without shelling out to git. const PINNED_CORE_REF: &str = include_str!("../../AGMSG_CORE_REF"); +/// The bundled agmsg-core version, stripped of its leading "v" (e.g. +/// "1.1.6") — the single source of truth both `agmsg_core_version_status` +/// (below) and the About dialog's version line (see make_menu in lib.rs) +/// read from, so they can never drift from each other. +pub(crate) fn pinned_core_version() -> String { + PINNED_CORE_REF.trim().trim_start_matches('v').to_string() +} + /// Parses a leading "X.Y.Z" out of a version string, ignoring anything after /// (git-describe suffixes like "-3-gabc1234", "-dirty", or a leading "v"). /// None for anything that doesn't start with a clean X.Y.Z — including the @@ -467,7 +475,7 @@ pub struct CoreVersionStatus { /// installs, or the literal "unknown") counts as outdated too. #[tauri::command] pub fn agmsg_core_version_status() -> CoreVersionStatus { - let pinned = PINNED_CORE_REF.trim().trim_start_matches('v').to_string(); + let pinned = pinned_core_version(); let installed = std::fs::read_to_string(agmsg_base().join("VERSION")) .ok() .map(|s| s.trim().to_string()) diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index b9bb2082..04b7921c 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -1,8 +1,10 @@ mod agmsg; +mod agent_state; mod menu_i18n; mod pty; use pty::PtyManager; +use serde::Serialize; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Mutex, OnceLock}; use tauri::menu::{AboutMetadataBuilder, CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu}; @@ -36,6 +38,21 @@ struct MenuLanguage(Mutex<String>); /// the checkbox via set_checked, and emits it to the frontend. struct UserChatVisible(AtomicBool); +/// Same idea as UserChatVisible, for View > Show Team Room — when off, the +/// frontend removes the Team Room tab entirely (not just its content), and +/// falls back to whichever pane tab is active, or an empty-state hint if +/// none exist yet. +struct TeamRoomVisible(AtomicBool); + +/// Handles to the View menu's two checkboxes — see make_menu's comment on +/// why these can't just be looked up via app.menu().get(id) each time. +/// Rebuilt (and these Mutexes overwritten) on every set_menu_language call, +/// since that constructs an entirely new Menu with fresh CheckMenuItems. +struct ViewMenuCheckboxes { + team_room: Mutex<CheckMenuItem<Wry>>, + user_chat: Mutex<CheckMenuItem<Wry>>, +} + /// Current webview zoom factor (1.0 = 100%). Tauri's WebviewWindow can set /// the zoom but not read it back, so this is the source of truth the Zoom /// In/Out/Actual Size menu items adjust and apply via set_zoom. @@ -183,19 +200,48 @@ fn log_path_import(message: &str) { /// Build the application menu in `lang`. macOS derives the default menu's /// About/Hide/Quit labels from the crate name (which can't contain a space), /// so we define them explicitly to read "agmsg" (matching productName) and -/// give About the real app icon. The Edit menu's Copy/Paste are also needed -/// for the embedded terminals. All labels come from menu_i18n::t so the -/// whole native menu tracks the app's language selector rather than the OS -/// locale. -fn make_menu(app: &AppHandle, lang: &str) -> tauri::Result<Menu<Wry>> { +/// give About the real app icon (macOS only — muda's Windows About dialog +/// ignores AboutMetadata.icon entirely, always showing the OS's own +/// info-bubble glyph; not fixable from here, see issue tracker). The Edit +/// menu's Copy/Paste are also needed for the embedded terminals. All labels +/// come from menu_i18n::t so the whole native menu tracks the app's +/// language selector rather than the OS locale. +fn make_menu(app: &AppHandle, lang: &str) -> tauri::Result<(Menu<Wry>, CheckMenuItem<Wry>, CheckMenuItem<Wry>)> { let name = "agmsg"; let m = |key: &str| menu_i18n::t(lang, "nativeMenu", key, &[]); let m_name = |key: &str| menu_i18n::t(lang, "nativeMenu", key, &[("name", name)]); let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")).ok(); + // This runs again on every language switch (set_menu_language rebuilds + // the whole menu — Tauri has no per-item relabel API), which used to + // hardcode both checkboxes back to checked regardless of their actual + // current state (koit bug report: the checkmark went stale after + // hiding Team Room, even though the real show/hide behavior was + // correct). try_state — not state, which panics — since the very + // FIRST call happens from the initial .menu(...) builder hook, before + // .manage() has registered either of these yet; true is the correct + // default for that one call only. + let team_room_checked = app.try_state::<TeamRoomVisible>().map(|s| s.0.load(Ordering::Relaxed)).unwrap_or(true); + let user_chat_checked = app.try_state::<UserChatVisible>().map(|s| s.0.load(Ordering::Relaxed)).unwrap_or(true); + // A single combined string (rather than muda's separate version/ + // short_version fields, which map to different, platform-divergent + // About-panel slots on macOS vs. a parenthetical suffix on Windows) so + // both platforms show the exact same text verbatim: "0.1.4 (core + // 1.1.6)". CARGO_PKG_VERSION is Cargo.toml's own version, always kept + // in sync with tauri.conf.json/package.json at release time; the core + // version is whatever AGMSG_CORE_REF this build bundled (agmsg:: + // pinned_core_version — the same source agmsg_core_version_status's + // "pinned" field reads, so the two can never disagree). + let version = format!("{} (core {})", env!("CARGO_PKG_VERSION"), agmsg::pinned_core_version()); let about = PredefinedMenuItem::about( app, Some(&m_name("about")), - Some(AboutMetadataBuilder::new().name(Some(name.to_string())).icon(icon).build()), + Some( + AboutMetadataBuilder::new() + .name(Some(name.to_string())) + .version(Some(version)) + .icon(icon) + .build(), + ), )?; let check_updates = MenuItem::with_id(app, CHECK_UPDATES_ID, m("checkForUpdates"), true, None::<&str>)?; @@ -231,9 +277,10 @@ fn make_menu(app: &AppHandle, lang: &str) -> tauri::Result<Menu<Wry>> { &PredefinedMenuItem::select_all(app, Some(&m("selectAll")))?, ], )?; - // "Show User Chat" toggles the app-user send/receive panel (chat + - // composer) in the frontend. The frontend owns the actual show/hide state; - // this checkbox just reflects it and emits a toggle event when clicked. + // "Show Team Room" / "Show User Chat" toggle the team-room tab and the + // app-user send/receive panel (chat + composer), respectively, in the + // frontend. The frontend owns the actual show/hide state; these + // checkboxes just reflect it and emit a toggle event when clicked. // "Pane Layout" duplicates the right-click-tab context menu's Layout // submenu (frontend App.tsx) here too — that one works fine but users // reported not discovering it, being tucked inside a right-click. The @@ -252,12 +299,25 @@ fn make_menu(app: &AppHandle, lang: &str) -> tauri::Result<Menu<Wry>> { &MenuItem::with_id(app, PANE_LAYOUT_TILE_ID, m("paneLayoutTile"), true, None::<&str>)?, ], )?; + // Owned locals (not inline in the items array below) so we can hand + // clones back to the caller — Menu::get()/Submenu::get() only search a + // menu's OWN direct children, never nested submenus, so app.menu(). + // get(TEAM_ROOM_MENU_ID) can never find an item that lives inside this + // view_menu; every other call site that needs to flip these checkboxes + // programmatically (on_menu_event, set_team_room_visible, + // set_user_chat_visible) holds one of these clones instead of + // re-searching a tree that doesn't contain them at the level searched. + let team_room_item = + CheckMenuItem::with_id(app, TEAM_ROOM_MENU_ID, m("showTeamRoom"), true, team_room_checked, None::<&str>)?; + let user_chat_item = + CheckMenuItem::with_id(app, USER_CHAT_MENU_ID, m("showUserChat"), true, user_chat_checked, None::<&str>)?; let view_menu = Submenu::with_items( app, m("viewMenu"), true, &[ - &CheckMenuItem::with_id(app, USER_CHAT_MENU_ID, m("showUserChat"), true, true, None::<&str>)?, + &team_room_item, + &user_chat_item, &PredefinedMenuItem::separator(app)?, &pane_layout_menu, &PredefinedMenuItem::separator(app)?, @@ -276,9 +336,11 @@ fn make_menu(app: &AppHandle, lang: &str) -> tauri::Result<Menu<Wry>> { &PredefinedMenuItem::close_window(app, Some(&m("closeWindow")))?, ], )?; - Menu::with_items(app, &[&app_menu, &edit_menu, &view_menu, &window_menu]) + let menu = Menu::with_items(app, &[&app_menu, &edit_menu, &view_menu, &window_menu])?; + Ok((menu, team_room_item, user_chat_item)) } +const TEAM_ROOM_MENU_ID: &str = "toggle_team_room"; const USER_CHAT_MENU_ID: &str = "toggle_user_chat"; const ZOOM_IN_ID: &str = "zoom_in"; const ZOOM_OUT_ID: &str = "zoom_out"; @@ -365,11 +427,59 @@ async fn check_for_updates(app: &AppHandle, user_initiated: bool) { #[tauri::command] fn set_menu_language(app: AppHandle, lang: String) -> Result<(), String> { *app.state::<MenuLanguage>().0.lock().unwrap() = lang.clone(); - let menu = make_menu(&app, &lang).map_err(|e| e.to_string())?; + let (menu, team_room_item, user_chat_item) = make_menu(&app, &lang).map_err(|e| e.to_string())?; app.set_menu(menu).map_err(|e| e.to_string())?; + let checkboxes = app.state::<ViewMenuCheckboxes>(); + *checkboxes.team_room.lock().unwrap() = team_room_item; + *checkboxes.user_chat.lock().unwrap() = user_chat_item; Ok(()) } +/// Called by the frontend when it changes showTeamRoom from a surface OTHER +/// than the View > Show Team Room checkbox itself (the tab's own right-click +/// "Hide Team Room" — see App.tsx) — keeps the native checkbox and +/// TeamRoomVisible in sync with a change the menu didn't originate, the +/// same way clicking the checkbox itself does (see TEAM_ROOM_MENU_ID's +/// on_menu_event handler). +#[tauri::command] +fn set_team_room_visible(app: AppHandle, visible: bool) { + app.state::<TeamRoomVisible>().0.store(visible, Ordering::Relaxed); + let _ = app.state::<ViewMenuCheckboxes>().team_room.lock().unwrap().set_checked(visible); +} + +/// Same idea as set_team_room_visible, for the chat pane header's own +/// right-click "Hide User Chat" (see App.tsx). +#[tauri::command] +fn set_user_chat_visible(app: AppHandle, visible: bool) { + app.state::<UserChatVisible>().0.store(visible, Ordering::Relaxed); + let _ = app.state::<ViewMenuCheckboxes>().user_chat.lock().unwrap().set_checked(visible); +} + +/// Rust holds the only durable copy of these two flags — the frontend's +/// own showTeamRoom/showUserChat state defaulted to `true` unconditionally +/// and only ever updated reactively (via the toggle-team-room/toggle-user- +/// chat events below), with no way to ask what the real value currently +/// is. That's invisible on a cold start (both sides agree on `true`), but +/// during `tauri dev` Vite hot-reloads the webview independently of this +/// Rust process — the frontend remounts and its state resets to `true` +/// while Rust (and the menu checkbox) still hold whatever was last set, +/// so the menu checkbox and the actual visible pane silently disagree. +/// The frontend now calls this once on mount to seed its state from here +/// instead of guessing. +#[derive(Serialize)] +pub struct ViewVisibility { + team_room: bool, + user_chat: bool, +} + +#[tauri::command] +fn view_visibility(app: AppHandle) -> ViewVisibility { + ViewVisibility { + team_room: app.state::<TeamRoomVisible>().0.load(Ordering::Relaxed), + user_chat: app.state::<UserChatVisible>().0.load(Ordering::Relaxed), + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -388,27 +498,40 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()) + // Restores the main window's size/position/maximized state on + // launch and saves it on move/resize/close — fully automatic, no + // frontend involvement needed (unlike zoom/view-visibility, which + // are app-specific state the frontend also reads/writes). + .plugin(tauri_plugin_window_state::Builder::default().build()) // Built in English first — the frontend doesn't get a chance to // report its actual language until after the webview loads and // i18next resolves it, so set_menu_language rebuilds this shortly // after startup with the real choice. - .menu(|app| make_menu(app, "en")) + .menu(|app| { + let (menu, team_room_item, user_chat_item) = make_menu(app, "en")?; + app.manage(ViewMenuCheckboxes { + team_room: Mutex::new(team_room_item), + user_chat: Mutex::new(user_chat_item), + }); + Ok(menu) + }) + .manage(TeamRoomVisible(AtomicBool::new(true))) .manage(UserChatVisible(AtomicBool::new(true))) .manage(ZoomLevel(Mutex::new(1.0))) .manage(MenuLanguage(Mutex::new("en".to_string()))) .on_menu_event(|app, event| { let id = event.id().as_ref(); - if id == USER_CHAT_MENU_ID { + if id == TEAM_ROOM_MENU_ID { + let state = app.state::<TeamRoomVisible>(); + let next = !state.0.load(Ordering::Relaxed); + state.0.store(next, Ordering::Relaxed); + let _ = app.state::<ViewMenuCheckboxes>().team_room.lock().unwrap().set_checked(next); + let _ = app.emit("toggle-team-room", next); + } else if id == USER_CHAT_MENU_ID { let state = app.state::<UserChatVisible>(); let next = !state.0.load(Ordering::Relaxed); state.0.store(next, Ordering::Relaxed); - if let Some(menu) = app.menu() { - if let Some(item) = menu.get(USER_CHAT_MENU_ID) { - if let Some(check) = item.as_check_menuitem() { - let _ = check.set_checked(next); - } - } - } + let _ = app.state::<ViewMenuCheckboxes>().user_chat.lock().unwrap().set_checked(next); let _ = app.emit("toggle-user-chat", next); } else if id == ZOOM_IN_ID || id == ZOOM_OUT_ID || id == ZOOM_RESET_ID { let state = app.state::<ZoomLevel>(); @@ -459,6 +582,7 @@ pub fn run() { } // Start the agmsg DB watcher so the team room updates live. agmsg::start_watcher(app.handle().clone()); + app.state::<PtyManager>().start_detection_tick(app.handle().clone()); // Quiet startup check — only surfaces a dialog when an update is // actually available (see check_for_updates's user_initiated flag). let app_handle = app.handle().clone(); @@ -473,6 +597,7 @@ pub fn run() { pty::pty_resize, pty::pty_kill, pty::pty_inject, + pty::agent_state, agmsg::agmsg_is_installed, agmsg::agmsg_install, agmsg::agmsg_core_version_status, @@ -489,6 +614,9 @@ pub fn run() { agmsg::agmsg_command_name, agmsg::agmsg_spawnable_types, set_menu_language, + set_team_room_visible, + set_user_chat_visible, + view_visibility, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/app/src-tauri/src/pty.rs b/app/src-tauri/src/pty.rs index 1da00c90..3f98d357 100644 --- a/app/src-tauri/src/pty.rs +++ b/app/src-tauri/src/pty.rs @@ -29,6 +29,8 @@ use portable_pty::{CommandBuilder, MasterPty, PtySize}; use serde::Serialize; use tauri::{AppHandle, Emitter, State}; +use crate::agent_state::{DetectionTracker, PaneState, TailBuffer, DETECTION_INTERVAL}; + /// One live PTY-backed agent terminal. struct PtySession { master: Box<dyn MasterPty + Send>, @@ -36,6 +38,8 @@ struct PtySession { /// Child process id, so closing a pane can actually terminate the agent /// (and let its SessionEnd hook release the agmsg actas lock). pid: Option<u32>, + tail: Arc<Mutex<TailBuffer>>, + detection: Arc<Mutex<DetectionTracker>>, } /// All live sessions, keyed by a frontend-chosen id (e.g. "claude-1"). @@ -45,6 +49,36 @@ pub struct PtyManager { sessions: Arc<Mutex<HashMap<String, PtySession>>>, } +impl PtyManager { + pub fn start_detection_tick(&self, app: AppHandle) { + let sessions = Arc::clone(&self.sessions); + thread::spawn(move || loop { + thread::sleep(DETECTION_INTERVAL); + let snapshots: Vec<(String, Arc<Mutex<TailBuffer>>, Arc<Mutex<DetectionTracker>>)> = sessions + .lock() + .unwrap() + .iter() + .map(|(id, session)| { + (id.clone(), Arc::clone(&session.tail), Arc::clone(&session.detection)) + }) + .collect(); + let now = std::time::Instant::now(); + for (id, tail, detection) in snapshots { + let tail = tail.lock().unwrap().detection_tail(); + if let Some(state) = detection.lock().unwrap().observe(&tail, now) { + let _ = app.emit("agent-state", AgentStateEvent { id, state }); + } + } + }); + } +} + +#[derive(Clone, Serialize)] +struct AgentStateEvent { + id: String, + state: PaneState, +} + #[derive(Clone, Serialize)] struct OutputEvent { id: String, @@ -170,17 +204,26 @@ pub fn pty_spawn( let mut reader = pair.master.try_clone_reader().map_err(|e| e.to_string())?; let writer = pair.master.take_writer().map_err(|e| e.to_string())?; + let tail = Arc::new(Mutex::new(TailBuffer::default())); + let agent_type = std::path::Path::new(&cmd) + .file_stem() + .and_then(|name| name.to_str()) + .unwrap_or(&cmd) + .to_ascii_lowercase(); + let detection = Arc::new(Mutex::new(DetectionTracker::new(agent_type))); // Reader thread: stream output to the webview. { let app = app.clone(); let id = id.clone(); + let reader_tail = Arc::clone(&tail); thread::spawn(move || { let mut buf = [0u8; 8192]; loop { match reader.read(&mut buf) { Ok(0) => break, Ok(n) => { + reader_tail.lock().unwrap().push(&buf[..n]); let b64 = base64::engine::general_purpose::STANDARD.encode(&buf[..n]); let _ = app.emit("pty-output", OutputEvent { id: id.clone(), b64 }); } @@ -193,10 +236,21 @@ pub fn pty_spawn( }); } - manager.sessions.lock().unwrap().insert(id, PtySession { master: pair.master, writer, pid }); + manager.sessions.lock().unwrap().insert( + id, + PtySession { master: pair.master, writer, pid, tail, detection }, + ); Ok(()) } +#[tauri::command] +pub fn agent_state(manager: State<'_, PtyManager>, id: String) -> Result<PaneState, String> { + let sessions = manager.sessions.lock().unwrap(); + let session = sessions.get(&id).ok_or("no such pty session")?; + let state = session.detection.lock().unwrap().state(); + Ok(state) +} + /// Forward keystrokes/data from xterm.js into the PTY. #[tauri::command] pub fn pty_write(manager: State<'_, PtyManager>, id: String, data: String) -> Result<(), String> { diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index fbd13724..9812aedb 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "agmsg", - "version": "0.1.4", + "version": "0.1.5", "identifier": "cc.agmsg.app", "build": { "beforeDevCommand": "pnpm dev", diff --git a/app/src/App.css b/app/src/App.css index b33d7b90..ba6af2a7 100644 --- a/app/src/App.css +++ b/app/src/App.css @@ -3,6 +3,11 @@ --panel: #11151f; --panel-2: #161b27; --border: #232a3a; + /* One step brighter than --border, specifically for the pane frame + (koit: --border alone was too faint to read as a visible box at + rest). Its own variable, not a --border change, so the other 27 + places --border is used elsewhere in the UI stay as-is. */ + --pane-border: #3a4660; --fg: #c5c8c6; --muted: #6b7280; --accent: #7aa2f7; @@ -155,21 +160,11 @@ select:focus { border-color: var(--accent); } -/* + New menu */ +/* + New menu (collapsed-rail only — the expanded sidebar has its own + direct + buttons per section instead, see .section-add-btn) */ .new-wrap { position: relative; } -.new-btn { - background: var(--panel-2); - color: var(--fg); - border: 1px solid var(--border); - border-radius: 6px; - padding: 4px 10px; - cursor: pointer; -} -.new-btn:hover { - border-color: var(--accent); -} .new-menu { position: absolute; top: 110%; @@ -270,12 +265,15 @@ select:focus { font-size: 12px; color: var(--muted); } -/* Marks the tab's current layout in the Layout ▸ submenu. */ -.submenu button.active { +/* Marks the current choice in a menu (e.g. the collapsed sidebar rail's + team-switch popup — see .rail-popup below). */ +.submenu button.active, +.ctx-menu button.active { color: var(--accent); font-weight: 600; } -.submenu button.active::before { +.submenu button.active::before, +.ctx-menu button.active::before { content: "✓ "; } @@ -474,14 +472,130 @@ body.resizing-row { display: flex; flex-direction: column; } +/* Collapsed to an icon-only rail (issue #317 backlog — koit) so panes get + more width; a fixed width overrides the draggable one from full size. + Spawning/messaging a member isn't offered from here at all — just + + (new team/agent), the current team (click to switch), and the app-user + avatar at the bottom (click for settings). */ +.sidebar.collapsed { + width: 44px; +} +/* Collapse/expand toggle — its own slim strip, level with the overlaid + macOS traffic lights (koit: not a tall empty row below them, and not + crowded into brand-row either — its own row, just short and right by the + lights rather than adding real vertical space). */ +.sidebar-toggle-row { + display: flex; + justify-content: flex-end; + align-items: center; + height: 28px; + padding: 0 6px; +} +.sidebar-collapse-toggle { + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + color: var(--muted); + cursor: pointer; + padding: 4px; + border-radius: 4px; + flex-shrink: 0; +} +.sidebar-collapse-toggle:hover { + background: var(--panel-2); + color: var(--fg); +} +.sidebar-collapsed-rail { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + /* No .sidebar-toggle-row above this in the collapsed state (it would + overlap the traffic lights at only 44px wide) — this provides its own + traffic-light clearance directly. */ + padding: 34px 0 12px; +} +.rail-logo-mark-btn { + background: none; + border: none; + padding: 3px; + cursor: pointer; + border-radius: 6px; + display: flex; +} +.rail-logo-mark-btn:hover { + background: var(--panel-2); +} +.rail-logo-mark { + width: 26px; + height: 26px; + display: block; +} +.rail-icon-btn { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + color: var(--fg); + font-size: 15px; + cursor: pointer; + border-radius: 6px; +} +.rail-icon-btn:hover { + background: var(--panel-2); +} +.rail-menu-wrap { + position: relative; +} +.rail-spacer { + flex: 1; +} +/* Clicking the avatar also expands the sidebar (koit) — same action as the + agmsg mark button above it, since it had no action at all before. */ +.rail-avatar-btn { + background: none; + border: none; + padding: 3px; + cursor: pointer; + border-radius: 50%; + display: flex; +} +.rail-avatar-btn:hover { + background: var(--panel-2); +} +.sidebar-collapsed-rail .avatar { + width: 24px; + height: 24px; +} +/* Popups the rail's + / team buttons open — anchored to their own trigger + button (position: relative on .rail-menu-wrap / .new-wrap) rather than + the click-coordinate positioning .ctx-menu normally uses elsewhere + (member/pane/tab right-click menus), since these always open from the + same fixed rail position. */ +.rail-popup { + position: absolute; + left: 100%; + top: 0; + margin-left: 4px; + z-index: 30; +} /* Sidebar header: brand + New + team selector (moved out of a top bar). */ .sidebar-head { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; - /* Top padding clears the overlaid macOS traffic lights. */ - padding: 34px 12px 12px; + /* Traffic-light clearance now lives on .sidebar-toggle-row above this + (same slot in both states) — this only needs its own small gap below + that row, not a second full clearance. */ + padding: 4px 12px 12px; border-bottom: 1px solid var(--border); } .brand-row { @@ -495,15 +609,72 @@ body.resizing-row { width: auto; display: block; } -.brand-row .new-wrap { - margin-left: auto; -} .sidebar-head .brand { font-size: 15px; } .sidebar-head select { width: 100%; } +.team-status-rail { + display: flex; + flex-direction: column; + gap: 3px; + border-bottom: 1px solid var(--border); + padding: 3px 6px; +} +.team-status-row { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 4px; + width: 100%; + padding: 6px 4px; + background: none; + border: none; + border-radius: 3px; + cursor: pointer; + color: inherit; + font-size: 11px; + text-align: left; +} +.team-status-row:hover, +.team-status-row.active { + background: var(--panel-2); +} +.team-status-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.team-status-slot { + flex-shrink: 0; + width: 13px; + display: flex; + align-items: center; + justify-content: center; +} +.team-status-dot { + flex: 0 0 auto; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--muted); +} +.team-status-dot.status-blocked { background: #f7768e; } +.team-status-dot.status-working { background: #e0af68; } +.team-status-dot.status-idle { background: var(--green); } +.team-status-dot.status-unknown { background: var(--green); } +.collapsed-team-status { + width: 28px; + padding: 2px; + border: 1px solid var(--border); + border-radius: 4px; +} +.collapsed-team-status .team-status-row { + justify-content: center; + gap: 0; + padding: 6px 0; +} .language-switcher { font-size: 11px; } @@ -516,6 +687,35 @@ body.resizing-row { text-transform: uppercase; letter-spacing: 0.6px; color: var(--muted); + white-space: nowrap; +} +.sidebar-title-label { + display: flex; + align-items: center; + gap: 6px; +} +.section-add-btn { + display: flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + padding: 0; + border: none; + border-radius: 3px; + background: var(--panel-2); + color: var(--muted); + font-size: 11px; + line-height: 1; + cursor: pointer; +} +.section-add-btn:hover { + background: var(--accent); + color: #fff; +} +.section-add-btn:disabled { + opacity: 0.4; + cursor: not-allowed; } .filter-actions { display: flex; @@ -523,6 +723,7 @@ body.resizing-row { gap: 4px; text-transform: none; letter-spacing: 0; + white-space: nowrap; } .filter-actions button { background: none; @@ -551,6 +752,14 @@ body.resizing-row { cursor: pointer; margin-left: 4px; } +.member-check-slot { + flex-shrink: 0; + width: 13px; + margin-left: 4px; + display: flex; + align-items: center; + justify-content: center; +} .member { flex: 1; min-width: 0; @@ -572,16 +781,26 @@ body.resizing-row { .member-name { font-weight: 600; } -/* Green dot marks a member that already has a live pane. */ -.running-dot { +.agent-status-dot { display: inline-block; width: 7px; height: 7px; - margin-left: 6px; border-radius: 50%; - background: var(--green); + background: var(--muted); vertical-align: middle; } +.agent-status-dot.status-blocked { background: #f7768e; } +.agent-status-dot.status-working { + background: #e0af68; + animation: agent-status-pulse 1.2s ease-in-out infinite; + animation-delay: var(--pulse-delay, 0s); +} +.agent-status-dot.status-idle { background: var(--green); } +.agent-status-dot.status-unknown { background: var(--green); } + +@keyframes agent-status-pulse { + 50% { opacity: 0.35; transform: scale(0.8); } +} .member-types { font-size: 11px; color: var(--muted); @@ -595,7 +814,9 @@ body.resizing-row { padding: 10px 12px; border-top: 1px solid var(--border); } -.sidebar-user .avatar { +/* Base look shared with the collapsed rail's own (smaller) avatar — see + .sidebar-collapsed-rail .avatar's size override below. */ +.avatar { width: 26px; height: 26px; border-radius: 50%; @@ -603,13 +824,14 @@ body.resizing-row { background: linear-gradient(135deg, var(--accent), #5566c4); } .settings-btn { + display: flex; + align-items: center; + justify-content: center; margin-left: auto; flex-shrink: 0; background: none; border: none; color: var(--muted); - font-size: 14px; - line-height: 1; cursor: pointer; padding: 4px; border-radius: 4px; @@ -654,6 +876,13 @@ body.resizing-row { border-bottom: 1px solid var(--border); overflow-x: auto; } +/* `hidden` alone loses to our `display: flex` above (same issue as + .appuser-chat[hidden] below) — spelled out for the chat pane's maximize + state, which hides the tab strip so the chat pane can fill the content + area. */ +.tabs[hidden] { + display: none; +} .tabs-drag-spacer { flex: 1; min-width: 24px; @@ -774,6 +1003,19 @@ body.resizing-row { padding: 20px; } +/* Show Team Room off + no pane tabs yet — the stage would otherwise be + entirely blank. */ +.stage-empty-hint { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--muted); + padding: 20px; + text-align: center; +} + .term-pane { width: 100%; height: 100%; @@ -816,7 +1058,7 @@ body.resizing-row { font-size: 11px; color: var(--muted); background: var(--panel); - border: 1px solid var(--border); + border: 1px solid var(--pane-border); border-bottom: none; border-radius: 6px 6px 0 0; } @@ -910,7 +1152,11 @@ body.resizing-row { .pane-divider-h::before { content: ""; position: absolute; - background: var(--border); + /* Invisible at rest now that the gap itself is a full terminal cell + (koit) — a static hairline sitting in the middle of that gap read as + a redundant, always-on divider line. Shows only on hover/while + actively dragging (below), same as before. */ + background: transparent; } .pane-divider-v::before { top: 0; @@ -926,13 +1172,15 @@ body.resizing-row { height: 1px; transform: translateY(-50%); } -.pane-divider-v:hover::before { +.pane-divider-v:hover::before, +.pane-divider-v.pane-divider-dragging::before { left: 0; width: 100%; transform: none; background: var(--accent); } -.pane-divider-h:hover::before { +.pane-divider-h:hover::before, +.pane-divider-h.pane-divider-dragging::before { top: 0; height: 100%; transform: none; @@ -1000,6 +1248,7 @@ body.resizing-row { height: 7px; border-radius: 50%; animation: monitor-pulse 2.2s ease-in-out infinite; + animation-delay: var(--pulse-delay, 0s); } .monitor-dot.native::before { background: var(--green); @@ -1055,14 +1304,72 @@ body.resizing-row { min-height: 0; width: 100%; height: auto; - border: 1px solid var(--border); + border: 1px solid var(--pane-border); border-top: none; } -/* App-user chat (you ↔ others), sits above the composer */ -.appuser-chat { - height: 160px; +/* App-user chat: header (min/max controls) + history + composer, all one + flex column so the 3 states (issue #317 backlog — koit) just resize + which pieces are visible/how much space they get, no separate hidden + toggles per piece anymore (see the JSX — only the wrap itself is hidden + via View > Show User Chat). */ +.appuser-chat-wrap { + display: flex; + flex-direction: column; flex-shrink: 0; +} +/* `hidden` alone loses to our `display: flex` above (same issue as .tabs + above) — spelled out for View > Show User Chat. */ +.appuser-chat-wrap[hidden] { + display: none; +} +/* Maximized: fill whatever space .main has left (the tab strip and stage + are hidden in this state — see their own [hidden] handling). Minimized: + shrink to just the header + composer's own content height, not the + chatHeight the normal state's inline style sets. */ +.appuser-chat-wrap.state-maximized { + flex: 1; + min-height: 0; +} +.appuser-chat-wrap.state-minimized { + flex-shrink: 0; +} +.appuser-chat-header { + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; + padding: 4px 8px 4px 12px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.6px; + color: var(--muted); + background: var(--panel); + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); +} +.appuser-chat-controls { + display: flex; + gap: 2px; +} +.chat-ctrl-btn { + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + color: var(--muted); + cursor: pointer; + padding: 4px 6px; + border-radius: 4px; +} +.chat-ctrl-btn:hover { + background: var(--panel-2); + color: var(--fg); +} +.appuser-chat { + flex: 1; + min-height: 0; overflow-y: auto; padding: 8px 12px; background: var(--panel-2); @@ -1071,13 +1378,6 @@ body.resizing-row { flex-direction: column; gap: 4px; } -/* `hidden` alone loses to our `display: flex` (author styles beat the UA - stylesheet's [hidden] rule), so spell it out for the panels View > Show - User Chat toggles. */ -.appuser-chat[hidden], -.composer[hidden] { - display: none; -} .chat-line { display: flex; gap: 8px; diff --git a/app/src/App.tsx b/app/src/App.tsx index a83d9c7a..dbe45c90 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -3,11 +3,24 @@ import { useTranslation } from "react-i18next"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { open as openDialog } from "@tauri-apps/plugin-dialog"; +import { + Maximize2, + Minimize2, + Minus, + PanelLeftClose, + RectangleHorizontal, + Settings, + Users, +} from "lucide-react"; import { TerminalPane } from "./TerminalPane"; +import { aggregateTeamStatus, applyStateChange, type PaneStatusMap, type RawState } from "./agentStatus"; +import { AUTO_TIMEZONE, formatMessageTime, isValidTimeZone, resolveTimeZone } from "./time"; import { AgentModal, AppUserModal, ConfirmModal, + MAX_TERMINAL_FONT_SIZE, + MIN_TERMINAL_FONT_SIZE, NewTeamModal, RenameModal, SettingsModal, @@ -18,6 +31,7 @@ import { clampRatio, collectDividers, computeRects, + dividerDragKey, insertAsNewLeaf, insertBeside, leaves, @@ -33,6 +47,7 @@ import { type PaneRect, type SplitNode, } from "./paneTree"; +import { PulseDot } from "./pulseSync"; import "./App.css"; export type Member = { name: string; types: string[]; project: string }; @@ -86,7 +101,7 @@ type DropPreview = { paneId: string; zone: ReturnType<typeof classifyDrop> }; type Modal = | { kind: "team"; firstRun: boolean } | { kind: "agent" } - | { kind: "appuser" } + | { kind: "appuser"; auto: boolean } | { kind: "rename"; current: string } | { kind: "leave"; name: string } | { kind: "settings" } @@ -103,6 +118,26 @@ const ROOM_PAGE_SIZE = 30; // enough for now; panes always start fresh each launch anyway (they're // live PTYs, not something a restart could restore even if we tried). const LAST_TEAM_KEY = "agmsg-app-last-team"; +// Persists the two View menu toggles below. See the seeding useEffect for +// why restoring these pushes INTO Rust (set_team_room_visible/ +// set_user_chat_visible) rather than reading view_visibility unconditionally. +const SHOW_TEAM_ROOM_KEY = "agmsg-app-show-team-room"; +const SHOW_USER_CHAT_KEY = "agmsg-app-show-user-chat"; +// Persists the sidebar's icon-only-rail collapse toggle. +const SIDEBAR_COLLAPSED_KEY = "agmsg-app-sidebar-collapsed"; +// Persists the terminal font size (Settings modal). xterm.js default is 15; +// this app has always hardcoded 12 (a bit denser), so that's the fallback. +const TERMINAL_FONT_SIZE_KEY = "agmsg-app-terminal-font-size"; +const DEFAULT_TERMINAL_FONT_SIZE = 12; +// "Don't show this again" for the auto-triggered app-user prompt (see the +// team-change effect below) — adding an app-user is always reachable from +// the chat's "Add one" link, so a user who dismisses the auto-prompt once +// shouldn't be re-asked on every future team switch. +const SUPPRESS_APPUSER_PROMPT_KEY = "agmsg-app-suppress-appuser-prompt"; +// Persists the Settings modal's timezone choice (see time.ts) — defaults to +// AUTO_TIMEZONE, which tracks the OS timezone live rather than freezing +// whatever was detected at first launch. +const TIMEZONE_KEY = "agmsg-app-timezone"; // Custom drag-and-drop MIME type for pane-swap drags (see PANE_DRAG_MIME // usages below) — a made-up type, not text/plain, so a stray OS file drag // or an unrelated drag elsewhere on the page never accidentally matches a @@ -137,6 +172,7 @@ export default function App() { const [members, setMembers] = useState<Member[]>([]); const [messages, setMessages] = useState<Message[]>([]); const [panes, setPanes] = useState<Pane[]>([]); + const [paneStatus, setPaneStatus] = useState<PaneStatusMap>({}); const [windows, setWindows] = useState<Window[]>([]); const [active, setActive] = useState<string>("room"); const [target, setTarget] = useState<string>(""); @@ -147,8 +183,68 @@ export default function App() { const [spawnTypes, setSpawnTypes] = useState<AgentType[]>([]); const [sidebarWidth, setSidebarWidth] = useState(200); const [chatHeight, setChatHeight] = useState(160); - // Toggled from the native "View > Show User Chat" menu item. - const [showUserChat, setShowUserChat] = useState(true); + // Terminal font size, adjustable from the Settings modal and persisted + // across restarts (see TERMINAL_FONT_SIZE_KEY). Validated against the same + // range the Settings <input> enforces — a stray/corrupted localStorage + // value (NaN, Infinity, out of range) would otherwise reach xterm as-is + // and persist itself right back on the next write. + const [terminalFontSize, setTerminalFontSize] = useState(() => { + const stored = Number(localStorage.getItem(TERMINAL_FONT_SIZE_KEY)); + return Number.isFinite(stored) && stored >= MIN_TERMINAL_FONT_SIZE && stored <= MAX_TERMINAL_FONT_SIZE + ? stored + : DEFAULT_TERMINAL_FONT_SIZE; + }); + // Timezone used to display chat/team-room timestamps (see time.ts and + // TIMEZONE_KEY) — "auto" (the default) tracks the OS timezone live. A + // corrupted/no-longer-recognized stored zone falls back to "auto" here + // too, so the Settings <select> always has a matching option instead of + // rendering blank. + const [timezone, setTimezone] = useState(() => { + const stored = localStorage.getItem(TIMEZONE_KEY); + if (!stored || stored === AUTO_TIMEZONE) return AUTO_TIMEZONE; + return isValidTimeZone(stored) ? stored : AUTO_TIMEZONE; + }); + // Collapses the team sidebar to an icon-only rail so panes get more width. + // Persisted across restarts (see SIDEBAR_COLLAPSED_KEY) — spawning/ + // messaging a member isn't offered from the collapsed rail; expand to get + // back to the full list. + const [sidebarCollapsed, setSidebarCollapsed] = useState( + () => localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "true", + ); + // Popup the collapsed rail's team-icon button opens — team switching, the + // one thing the full sidebar's team <select> offers that needs a menu + // instead of a direct icon action at rail width. Settings is a direct + // button in the rail (same as the full view's gear icon), no popup needed. + const [railTeamMenu, setRailTeamMenu] = useState(false); + // Toggled from the native "View > Show Team Room" menu item — when off, + // the room tab itself disappears from the tab bar (not just its + // content), matching Show User Chat's own toggle just below it. + // Lazily restored from localStorage (see SHOW_TEAM_ROOM_KEY) rather than + // always starting `true` and correcting via an effect — the mount-time + // seeding effect below still exists, but only to push this already- + // correct initial value INTO Rust, not to setState a second time (that + // would race the persist effect just below it: a persist effect keyed on + // [showTeamRoom] captures the value AS OF THE RENDER IT WAS DECLARED IN, + // so if this state started `true` and got corrected by a later setState, + // the initial-render persist effect would already have fired and written + // the stale `true` back to localStorage before the correction landed). + const [showTeamRoom, setShowTeamRoom] = useState( + () => localStorage.getItem(SHOW_TEAM_ROOM_KEY) !== "false", + ); + // Toggled from the native "View > Show User Chat" menu item. See + // showTeamRoom just above for why this is lazily restored. + const [showUserChat, setShowUserChat] = useState( + () => localStorage.getItem(SHOW_USER_CHAT_KEY) !== "false", + ); + // The app-user chat pane's 3 states (session-only, like sidebarCollapsed): + // "normal" (today's layout), "minimized" (collapses the history away, + // leaving just the composer row — the team room becomes the only log, + // fixing the double-log fatigue real users reported), "maximized" (the + // chat pane fills the whole content area, hiding the team room/agent + // panes — a focused 1:1 view). Windows-style min/max header buttons + // toggle these; clicking maximize while minimized (or vice versa) jumps + // straight to the other state, same as real OS window controls. + const [chatPaneState, setChatPaneState] = useState<"normal" | "minimized" | "maximized">("normal"); // Team-room member filter: names the user has UN-checked (default: all shown). const [deselected, setDeselected] = useState<Set<string>>(new Set()); // Team-room history paging: whether an older page exists, and whether one @@ -170,6 +266,46 @@ export default function App() { const [windowMenu, setWindowMenu] = useState<{ windowId: string; x: number; y: number } | null>( null, ); + // Right-click context menu over the Team Room tab itself: { x, y }. Just + // one action (Hide Team Room) — no per-window data needed, unlike windowMenu. + const [roomMenu, setRoomMenu] = useState<{ x: number; y: number } | null>(null); + // Same idea, over the app-user chat pane's own header: { x, y }. + const [chatMenu, setChatMenu] = useState<{ x: number; y: number } | null>(null); + + // Closes every context/dropdown menu (koit: opening a second one while a + // first is still open used to stack both — right-clicking a different + // target, or clicking a different trigger button, never went through the + // background click-away handler that normally closes these, since it's + // a completely separate element being interacted with). Every + // menu-opening handler below calls this first. + const closeAllMenus = useCallback(() => { + setNewMenu(false); + setMemberMenu(null); + setPaneMenu(null); + setWindowMenu(null); + setRoomMenu(null); + setChatMenu(null); + setRailTeamMenu(false); + }, []); + // The two menus opened by clicking a trigger button (rather than + // right-clicking somewhere) toggle themselves off on a second click of + // their OWN button — closeAllMenus alone would fight that (it'd close + // then the plain !v toggle would immediately reopen it), so these check + // "is it already open" first and only close-all-others when opening fresh. + const toggleNewMenu = useCallback(() => { + if (newMenu) setNewMenu(false); + else { + closeAllMenus(); + setNewMenu(true); + } + }, [newMenu, closeAllMenus]); + const toggleRailTeamMenu = useCallback(() => { + if (railTeamMenu) setRailTeamMenu(false); + else { + closeAllMenus(); + setRailTeamMenu(true); + } + }, [railTeamMenu, closeAllMenus]); // Swap two panes' positions by name: click one pane's name to "pick it // up" (its id goes here), then click another pane's name in the same // window to swap. Click the same name again to cancel. Also doubles as @@ -227,6 +363,29 @@ export default function App() { panesRef.current = panes; const windowsRef = useRef<Window[]>([]); windowsRef.current = windows; + const applyAgentState = useCallback((paneId: string, state: RawState) => { + setPaneStatus((current) => applyStateChange(current, paneId, state)); + }, []); + + // Cell size in CSS px. The ref is for snapping a divider drag to whole + // terminal rows/cols — read once at drag-start, doesn't need a re-render + // on every fit. The state twin drives the gap BETWEEN panes below (koit: + // should be a full terminal cell per axis, herdr-style, not an arbitrary + // fixed px value) — that one has to be real React state since it feeds + // rendered CSS. Every pane uses the same fixed font today, so any one of + // them reporting is representative of them all. + const cellSizeRef = useRef<{ w: number; h: number } | null>(null); + const [cellSize, setCellSize] = useState<{ w: number; h: number } | null>(null); + const handleCellSize = useCallback((w: number, h: number) => { + cellSizeRef.current = { w, h }; + setCellSize((prev) => (prev && prev.w === w && prev.h === h ? prev : { w, h })); + }, []); + + // Which divider (by dividerDragKey, below) is currently being dragged — + // state, not a captured DOM element, so the highlight survives a + // grid-segment transpose remounting the divider mid-drag (co1 review, + // PR #390). + const [draggingDividerKey, setDraggingDividerKey] = useState<string | null>(null); // The app user = the member registered with the agmsg-app type (one per team). const appUserMember = members.find((m) => m.types.includes(APP_USER_TYPE)); @@ -284,12 +443,69 @@ export default function App() { invoke<string>("agmsg_command_name").then(setCmdName).catch(() => {}); }, []); + // showTeamRoom/showUserChat are already correctly seeded from localStorage + // by their useState initializers above — this effect's only job is to + // PUSH that initial value into Rust (set_team_room_visible/ + // set_user_chat_visible, which also resyncs the native menu checkbox), so + // Rust doesn't sit at its own process-default `true` while the frontend + // shows something else. No setState here: doing so would re-introduce the + // write-then-correct race this replaced (a persist effect below, keyed on + // showTeamRoom/showUserChat, would capture the stale initial-render value + // and write it back to localStorage before a corrective setState's + // re-render could fix it). + // + // Falls back to Rust's own view_visibility only when localStorage has + // nothing stored yet (true first run) — this also covers a webview + // remount that doesn't restart the Rust process (Vite HMR during + // `tauri dev`), where Rust may hold a value localStorage hasn't caught + // up to yet. + useEffect(() => { + const storedTeamRoom = localStorage.getItem(SHOW_TEAM_ROOM_KEY); + const storedUserChat = localStorage.getItem(SHOW_USER_CHAT_KEY); + if (storedTeamRoom !== null) void invoke("set_team_room_visible", { visible: storedTeamRoom === "true" }); + if (storedUserChat !== null) void invoke("set_user_chat_visible", { visible: storedUserChat === "true" }); + if (storedTeamRoom === null || storedUserChat === null) { + invoke<{ team_room: boolean; user_chat: boolean }>("view_visibility") + .then((v) => { + if (storedTeamRoom === null) setShowTeamRoom(v.team_room); + if (storedUserChat === null) setShowUserChat(v.user_chat); + }) + .catch(() => {}); + } + }, []); + + // Native "View > Show Team Room" menu checkbox toggles the room tab itself. + useEffect(() => { + const p = listen<boolean>("toggle-team-room", (e) => setShowTeamRoom(e.payload)); + return () => void p.then((u) => u()); + }, []); + // Native "View > Show User Chat" menu checkbox toggles the chat/composer panel. useEffect(() => { const p = listen<boolean>("toggle-user-chat", (e) => setShowUserChat(e.payload)); return () => void p.then((u) => u()); }, []); + // Persist the two View toggles and the sidebar collapse state across + // restarts (see their _KEY constants above). + useEffect(() => { + localStorage.setItem(SHOW_TEAM_ROOM_KEY, String(showTeamRoom)); + }, [showTeamRoom]); + useEffect(() => { + localStorage.setItem(SHOW_USER_CHAT_KEY, String(showUserChat)); + }, [showUserChat]); + useEffect(() => { + localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(sidebarCollapsed)); + }, [sidebarCollapsed]); + useEffect(() => { + localStorage.setItem(TERMINAL_FONT_SIZE_KEY, String(terminalFontSize)); + }, [terminalFontSize]); + useEffect(() => { + localStorage.setItem(TIMEZONE_KEY, timezone); + }, [timezone]); + // Resolved once per render for both message-time display sites below. + const effectiveTimeZone = resolveTimeZone(timezone); + // Spawnable agent types (for the Add-agent type picker). spawnMember // re-fetches this itself right before spawning — see there for why. useEffect(() => { @@ -370,8 +586,11 @@ export default function App() { .catch(console.error); loadMembers(team) .then((m) => { - if (!m.some((x) => x.types.includes(APP_USER_TYPE))) { - setModal((cur) => cur ?? { kind: "appuser" }); + if ( + !m.some((x) => x.types.includes(APP_USER_TYPE)) && + localStorage.getItem(SUPPRESS_APPUSER_PROMPT_KEY) !== "true" + ) { + setModal((cur) => cur ?? { kind: "appuser", auto: true }); } }) .catch(console.error); @@ -410,6 +629,24 @@ export default function App() { return () => void p.then((u) => u()); }, [team, cmdName]); + useEffect(() => { + const stateListener = listen<{ id: string; state: RawState }>("agent-state", (event) => { + applyAgentState(event.payload.id, event.payload.state); + }); + const exitListener = listen<{ id: string }>("pty-exit", (event) => { + setPaneStatus((current) => { + if (!(event.payload.id in current)) return current; + const next = { ...current }; + delete next[event.payload.id]; + return next; + }); + }); + return () => { + void stateListener.then((unlisten) => unlisten()); + void exitListener.then((unlisten) => unlisten()); + }; + }, [applyAgentState]); + // Load-more-on-scroll-up: fetch the page older than the currently-oldest // loaded message and prepend it, restoring the scroll position afterward // (a naive prepend would otherwise yank the view down by the new content's @@ -688,6 +925,28 @@ export default function App() { // teams' windows stay mounted with their PTYs alive, just not listed // here or offered as spawn/move targets. const teamWindows = useMemo(() => windows.filter((w) => w.team === team), [windows, team]); + const teamStatusByName = useMemo(() => { + return Object.fromEntries( + teams.map((teamName) => { + const paneIds = windows + .filter((window) => window.team === teamName) + .flatMap((window) => leaves(window.root)); + const statuses = paneIds.map((paneId) => paneStatus[paneId] ?? { state: "unknown" as const }); + return [teamName, aggregateTeamStatus(statuses)]; + }), + ); + }, [teams, windows, paneStatus]); + + // If Show Team Room gets switched off while it's the active tab, land on + // whichever pane tab exists instead — the room tab itself is about to + // disappear from the bar, so "active" can't keep pointing at it. If there + // are no pane tabs either, there's genuinely nothing to switch to yet; + // the stage below shows a hint in that case rather than a blank area. + useEffect(() => { + if (!showTeamRoom && active === "room" && teamWindows.length > 0) { + setActive(teamWindows[0].id); + } + }, [showTeamRoom, active, teamWindows]); // Every window's leaf rects, computed once per render rather than per-pane // (computeRects walks the whole tree) — looked up by window id, then pane @@ -809,8 +1068,12 @@ export default function App() { e.preventDefault(); const startX = e.clientX; const startW = sidebarWidth; + // 180, not 140 — narrower than that wraps the brand-row's + New + // button and the sidebar-title row's All/None filter links onto a + // second line (koit). Full collapse (the rail toggle) is the way to + // go narrower than a usable full sidebar now anyway. const onMove = (ev: MouseEvent) => - setSidebarWidth(Math.max(140, Math.min(520, startW + ev.clientX - startX))); + setSidebarWidth(Math.max(180, Math.min(520, startW + ev.clientX - startX))); const onUp = () => { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); @@ -844,6 +1107,16 @@ export default function App() { [chatHeight], ); + // Chat pane min/max: clicking one while the OTHER state is active jumps + // straight there (minimize while maximized goes directly to minimized, + // not back through normal first) — same as real OS window controls. + const toggleChatMinimized = useCallback(() => { + setChatPaneState((s) => (s === "minimized" ? "normal" : "minimized")); + }, []); + const toggleChatMaximized = useCallback(() => { + setChatPaneState((s) => (s === "maximized" ? "normal" : "maximized")); + }, []); + // Draggable pane dividers (issue #317). Same document-level drag-tracking // pattern as startSidebarDrag/startChatDrag above, but the ratio being // dragged belongs to one specific split node rather than a single flat @@ -889,10 +1162,18 @@ export default function App() { // own doc). const MIN_PANE_PX = 120; const cursorClass = axis === "col" ? "resizing-col" : "resizing-row"; + // koit: prefers the divider snapping to whole terminal cells over a + // free pixel drag (herdr-inspired, though herdr itself had nothing + // reusable here — this is agmsg's own design). Every pane shares the + // same fixed font, so one representative cell size (captured by any + // TerminalPane's fit) is enough regardless of which panes flank this + // specific divider. + const cellPx = axis === "col" ? cellSizeRef.current?.w : cellSizeRef.current?.h; const onMove = (ev: MouseEvent) => { const totalPx = axis === "col" ? parentPx.width : parentPx.height; const raw = axis === "col" ? (ev.clientX - parentPx.left) / totalPx : (ev.clientY - parentPx.top) / totalPx; - const ratio = clampRatio(raw, MIN_PANE_PX, totalPx); + const snapped = cellPx ? (Math.round((raw * totalPx) / cellPx) * cellPx) / totalPx : raw; + const ratio = clampRatio(snapped, MIN_PANE_PX, totalPx); setWindows((prev) => prev.map((w) => (w.id === windowId ? { ...w, root: updateRatioAtPath(w.root, dragPath, ratio) } : w)), ); @@ -901,21 +1182,22 @@ export default function App() { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); document.body.classList.remove(cursorClass); + setDraggingDividerKey(null); }; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); + // dragPath.join(".") is this divider's identity on BOTH sides of the + // grid-segment transpose queued above (see dividerDragKey's doc) — key + // off that, not a captured DOM element, so the highlight survives even + // if this transpose swaps in a differently-shaped divider set. + setDraggingDividerKey(dragPath.join(".") || "root"); document.body.classList.add(cursorClass); }, []); return ( <div className="app" - onClick={() => { - setNewMenu(false); - setMemberMenu(null); - setPaneMenu(null); - setWindowMenu(null); - }} + onClick={closeAllMenus} onDragOverCapture={(e) => { // WebKit doesn't reliably show a "no-drop" cursor just from // dropEffect = "none" — it only trusts that value once something in @@ -986,28 +1268,182 @@ export default function App() { </div> )} <div className="body"> - <aside className="sidebar" style={{ width: sidebarWidth }}> - <div className="sidebar-head" data-tauri-drag-region> - <div className="brand-row"> - <img className="logo" src="/agmsg-logo.png" alt={t("sidebar.logoAlt")} /> + <aside + className={sidebarCollapsed ? "sidebar collapsed" : "sidebar"} + style={{ width: sidebarCollapsed ? undefined : sidebarWidth }} + > + {/* Collapse toggle — level with the traffic lights, expanded state + only (koit: it looked fine there, "closing is perfect"). At + 44px wide the collapsed sidebar sits entirely under the + traffic-light cluster, so ANY button in this same slim strip + would overlap them there — collapsed state expands via the + agmsg mark itself instead (below), not a dedicated button. */} + {!sidebarCollapsed && ( + <div className="sidebar-toggle-row" data-tauri-drag-region> + <button + className="sidebar-collapse-toggle" + title={t("sidebar.collapse")} + onClick={() => setSidebarCollapsed(true)} + > + <PanelLeftClose size={16} /> + </button> + </div> + )} + {sidebarCollapsed ? ( + // Icon-only rail (koit design): the agmsg mark (click to + // expand) → + (new team/agent, same menu as full view) → team + // icon (click opens a team-switch popup, replacing the + // <select>) → spacer → the app-user avatar + a settings button + // at the bottom. No running-dot / member list — spawning/ + // messaging isn't offered from here at all. Icons are lucide + // (koit: prefer a real icon set over ad-hoc unicode + // glyphs/hand-drawn SVGs). + <div className="sidebar-collapsed-rail"> + <button + className="rail-logo-mark-btn" + title={t("sidebar.expand")} + onClick={() => setSidebarCollapsed(false)} + > + <img className="rail-logo-mark" src="/agmsg-mark.png" alt={t("sidebar.logoAlt")} /> + </button> + <div className="new-wrap" onClick={(e) => e.stopPropagation()}> - <button className="new-btn" onClick={() => setNewMenu((v) => !v)}> - {t("sidebar.newMenu.trigger")} + <button className="rail-icon-btn" title={t("sidebar.newMenu.trigger")} onClick={toggleNewMenu}> + + + </button> + {newMenu && ( + <div className="new-menu rail-popup"> + <button + onClick={() => { + setNewMenu(false); + setModal({ kind: "team", firstRun: false }); + }} + > + {t("sidebar.newMenu.team")} + </button> + <button + disabled={!team} + onClick={() => { + setNewMenu(false); + setModal({ kind: "agent" }); + invoke<AgentType[]>("agmsg_spawnable_types") + .then(setSpawnTypes) + .catch(() => {}); + }} + > + {t("sidebar.newMenu.agent")} + </button> + </div> + )} + </div> + + {team && ( + <div className="rail-menu-wrap" onClick={(e) => e.stopPropagation()}> + <button className="rail-icon-btn" title={team} onClick={toggleRailTeamMenu}> + <Users size={16} /> + </button> + {railTeamMenu && ( + <div className="ctx-menu rail-popup"> + {teams.map((teamName) => ( + <button + key={teamName} + className={teamName === team ? "active" : undefined} + onClick={() => { + setTeam(teamName); + setRailTeamMenu(false); + }} + > + {teamName} + </button> + ))} + </div> + )} + </div> + )} + + <div className="team-status-rail collapsed-team-status" aria-label="Open pane status"> + {teams.map((teamName) => { + const status = teamStatusByName[teamName] ?? "unknown"; + return ( + <button + key={teamName} + className={teamName === team ? "team-status-row active" : "team-status-row"} + title={`${teamName}: ${status} (open panes)`} + onClick={() => setTeam(teamName)} + > + <span className={`team-status-dot status-${status}`} /> + </button> + ); + })} + </div> + + <div className="rail-spacer" /> + + {appUser && ( + <button + className="rail-avatar-btn" + title={t("sidebar.expand")} + onClick={() => setSidebarCollapsed(false)} + > + <span className="avatar" title={t("sidebar.user.title", { team })} /> + </button> + )} + <button + className="rail-icon-btn" + title={t("settings.title")} + onClick={(e) => { + e.stopPropagation(); + setModal({ kind: "settings" }); + }} + > + <Settings size={16} /> </button> - {newMenu && ( - <div className="new-menu"> + </div> + ) : ( + <> + <div className="sidebar-head" data-tauri-drag-region> + <div className="brand-row"> + <img className="logo" src="/agmsg-logo.png" alt={t("sidebar.logoAlt")} /> + </div> + </div> + <div className="sidebar-title"> + <span className="sidebar-title-label"> + {t("sidebar.team.title")} <button - onClick={() => { - setNewMenu(false); - setModal({ kind: "team", firstRun: false }); - }} + className="section-add-btn" + title={t("sidebar.team.new")} + onClick={() => setModal({ kind: "team", firstRun: false })} > - {t("sidebar.newMenu.team")} + + </button> + </span> + </div> + <div className="team-status-rail" aria-label="Open pane status"> + {teams.map((teamName) => { + const status = teamStatusByName[teamName] ?? "unknown"; + return ( + <button + key={teamName} + className={teamName === team ? "team-status-row active" : "team-status-row"} + title={`${teamName}: ${status} (open panes)`} + onClick={() => setTeam(teamName)} + > + <span className="team-status-slot"> + <span className={`team-status-dot status-${status}`} /> + </span> + <span className="team-status-name">{teamName}</span> + </button> + ); + })} + </div> + <div className="sidebar-title"> + <span className="sidebar-title-label"> + {t("sidebar.title")} <button + className="section-add-btn" + title={t("sidebar.newAgent")} disabled={!team} onClick={() => { - setNewMenu(false); setModal({ kind: "agent" }); // Refresh in case spawn-options.yaml or a new type // manifest showed up since app start. @@ -1016,104 +1452,123 @@ export default function App() { .catch(() => {}); }} > - {t("sidebar.newMenu.agent")} + + + </button> + </span> + {active === "room" && others.length > 0 && ( + <span className="filter-actions"> + <button onClick={selectAllMembers}>{t("sidebar.filter.all")}</button> + <span>·</span> + <button onClick={selectNoMembers}>{t("sidebar.filter.none")}</button> + </span> + )} + </div> + <ul className="members"> + {others.map((m) => { + const pane = panes.find( + (candidate) => + candidate.label === m.name && + windows.some( + (window) => + window.team === team && leaves(window.root).includes(candidate.id), + ), + ); + const status = pane ? (paneStatus[pane.id]?.state ?? "unknown") : null; + return ( + <li + key={m.name} + className="member-row" + onContextMenu={(e) => { + e.preventDefault(); + e.stopPropagation(); + closeAllMenus(); + setMemberMenu({ member: m, x: e.clientX, y: e.clientY }); + }} + > + {active === "room" ? ( + <input + type="checkbox" + className="member-check" + title={t("sidebar.member.checkboxTitle")} + checked={!deselected.has(m.name)} + onChange={() => toggleMember(m.name)} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + <span className="member-check-slot"> + {status && ( + <PulseDot + periodMs={1200} + active={status === "working"} + className={`agent-status-dot status-${status}`} + title={status} + /> + )} + </span> + )} + <button + className="member" + onClick={() => spawnMember(m)} + title={ + pane + ? t("sidebar.member.titleRunning") + : t("sidebar.member.titleSpawn") + } + > + <span className="member-name">{m.name}</span> + <span className="member-types"> + {m.types.join(", ") || t("sidebar.member.noTypes")} + </span> + </button> + </li> + ); + })} + {others.length === 0 && ( + <li className="empty">{t("sidebar.member.emptyState")}</li> + )} + </ul> + {appUser && ( + <div className="sidebar-user" title={t("sidebar.user.title", { team })}> + <span className="avatar" /> + <div className="su-meta"> + <span className="su-name">{appUser}</span> + <span className="su-team">{team}</span> + </div> + <button + className="settings-btn" + title={t("settings.title")} + onClick={(e) => { + e.stopPropagation(); + setModal({ kind: "settings" }); + }} + > + <Settings size={15} /> </button> </div> )} - </div> - </div> - <select value={team} onChange={(e) => setTeam(e.target.value)}> - {teams.map((teamName) => ( - <option key={teamName} value={teamName}> - {teamName} - </option> - ))} - </select> - </div> - <div className="sidebar-title"> - <span>{t("sidebar.title")}</span> - {active === "room" && others.length > 0 && ( - <span className="filter-actions"> - <button onClick={selectAllMembers}>{t("sidebar.filter.all")}</button> - <span>·</span> - <button onClick={selectNoMembers}>{t("sidebar.filter.none")}</button> - </span> - )} - </div> - <ul className="members"> - {others.map((m) => ( - <li - key={m.name} - className="member-row" + </> + )} + </aside> + + {!sidebarCollapsed && <div className="divider-v" onMouseDown={startSidebarDrag} />} + + <main className="main"> + <nav className="tabs" data-tauri-drag-region hidden={showUserChat && chatPaneState === "maximized"}> + {showTeamRoom && ( + <span + className={active === "room" ? "tab active" : "tab"} onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); - setMemberMenu({ member: m, x: e.clientX, y: e.clientY }); + closeAllMenus(); + setRoomMenu({ x: e.clientX, y: e.clientY }); }} > - {active === "room" && ( - <input - type="checkbox" - className="member-check" - title={t("sidebar.member.checkboxTitle")} - checked={!deselected.has(m.name)} - onChange={() => toggleMember(m.name)} - onClick={(e) => e.stopPropagation()} - /> - )} - <button - className="member" - onClick={() => spawnMember(m)} - title={ - panes.some((p) => p.label === m.name) - ? t("sidebar.member.titleRunning") - : t("sidebar.member.titleSpawn") - } - > - <span className="member-name"> - {m.name} - {panes.some((p) => p.label === m.name) && <span className="running-dot" />} - </span> - <span className="member-types"> - {m.types.join(", ") || t("sidebar.member.noTypes")} - </span> + <button className="tab-label" onClick={() => setActive("room")}> + {t("tabs.roomLabel")} </button> - </li> - ))} - {others.length === 0 && ( - <li className="empty">{t("sidebar.member.emptyState")}</li> + </span> )} - </ul> - {appUser && ( - <div className="sidebar-user" title={t("sidebar.user.title", { team })}> - <span className="avatar" /> - <div className="su-meta"> - <span className="su-name">{appUser}</span> - <span className="su-team">{team}</span> - </div> - <button - className="settings-btn" - title={t("settings.title")} - onClick={(e) => { - e.stopPropagation(); - setModal({ kind: "settings" }); - }} - > - ⚙ - </button> - </div> - )} - </aside> - - <div className="divider-v" onMouseDown={startSidebarDrag} /> - - <main className="main"> - <nav className="tabs" data-tauri-drag-region> - <span className={active === "room" ? "tab active" : "tab"}> - <button className="tab-label" onClick={() => setActive("room")}> - {t("tabs.roomLabel")} - </button> - </span> {teamWindows.map((w) => ( <span key={w.id} @@ -1126,6 +1581,7 @@ export default function App() { onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); + closeAllMenus(); setWindowMenu({ windowId: w.id, x: e.clientX, y: e.clientY }); }} onDragOver={(e) => { @@ -1204,10 +1660,10 @@ export default function App() { /> </nav> - <section className="stage"> + <section className="stage" hidden={showUserChat && chatPaneState === "maximized"}> <div className="room" - hidden={active !== "room"} + hidden={active !== "room" || !showTeamRoom} ref={feedRef} onScroll={(e) => { if (e.currentTarget.scrollTop < 60) void loadOlderMessages(); @@ -1220,7 +1676,7 @@ export default function App() { <b className="mf">{g.from}</b> <span className="arrow">→</span> <b className="mt">{g.to}</b> - <span className="grp-time">{g.items[0].created_at.slice(11, 19)}</span> + <span className="grp-time">{formatMessageTime(g.items[0].created_at, effectiveTimeZone)}</span> </div> {g.items.map((m) => ( <div className="grp-body" key={m.id}> @@ -1232,6 +1688,13 @@ export default function App() { {messages.length === 0 && <div className="empty">{t("room.emptyState")}</div>} </div> + {/* Show Team Room is off AND no pane tabs exist yet — the stage + would otherwise be entirely blank (no room, nothing else to + switch "active" to). */} + {!showTeamRoom && teamWindows.length === 0 && ( + <div className="stage-empty-hint">{t("stage.emptyHint")}</div> + )} + {/* Every pane is a permanent, flat child of .stage — its window only decides WHERE it's positioned (computeRects, from its split tree), never whether it's mounted. That keeps each pane's DOM @@ -1262,6 +1725,17 @@ export default function App() { top: `${rect.top}%`, width: `${rect.width}%`, height: `${rect.height}%`, + // The gap between two adjacent panes is each side's own + // padding added together, so half a cell per side makes + // a full terminal cell of gap at the shared seam + // (koit/herdr: the gap should read as "one cell", not + // an arbitrary fixed px value — falls back to the old + // fixed padding before any pane has fit and reported + // its real cell size). + paddingLeft: cellSize ? cellSize.w / 2 : undefined, + paddingRight: cellSize ? cellSize.w / 2 : undefined, + paddingTop: cellSize ? cellSize.h / 2 : undefined, + paddingBottom: cellSize ? cellSize.h / 2 : undefined, }} onDragOver={(e) => { // Gate on dataTransfer.types, NOT React state: dragover @@ -1316,6 +1790,7 @@ export default function App() { onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); + closeAllMenus(); setPaneMenu({ paneId: p.id, windowId: win.id, x: e.clientX, y: e.clientY }); }} > @@ -1365,13 +1840,16 @@ export default function App() { > {p.label} </button> - <span className={p.native ? "monitor-dot native" : "monitor-dot app"}> + <PulseDot + periodMs={2200} + className={p.native ? "monitor-dot native" : "monitor-dot app"} + > <span className="monitor-tip"> {p.native ? t("pane.monitorTip.native") : t("pane.monitorTip.app")} </span> - </span> + </PulseDot> <button className="pane-header-close" onClick={() => setModal({ kind: "closePane", paneId: p.id })} @@ -1380,100 +1858,168 @@ export default function App() { × </button> </div> - <TerminalPane id={p.id} cmd={p.cmd} args={p.args} cwd={p.cwd} /> + <TerminalPane + id={p.id} + cmd={p.cmd} + args={p.args} + cwd={p.cwd} + fontSize={terminalFontSize} + onAgentState={applyAgentState} + onCellSize={handleCellSize} + /> </div> ); })} {active !== "room" && - activeDividers.map((d) => ( - <div - key={ - d.kind === "single" - ? `single:${d.path.join(".") || "root"}` - : `grid:${d.basePath.join(".") || "root"}:${d.segmentPath.join(".")}` - } - className={d.axis === "col" ? "pane-divider-v" : "pane-divider-h"} - style={{ - left: `${d.rect.left}%`, - top: `${d.rect.top}%`, - width: `${d.rect.width}%`, - height: `${d.rect.height}%`, - }} - onMouseDown={(e) => startPaneDividerDrag(e, active, d)} - /> - ))} + activeDividers.map((d) => { + const isDragging = draggingDividerKey !== null && dividerDragKey(d) === draggingDividerKey; + return ( + <div + key={ + d.kind === "single" + ? `single:${d.path.join(".") || "root"}` + : `grid:${d.basePath.join(".") || "root"}:${d.segmentPath.join(".")}` + } + className={[ + d.axis === "col" ? "pane-divider-v" : "pane-divider-h", + isDragging && "pane-divider-dragging", + ] + .filter(Boolean) + .join(" ")} + style={{ + left: `${d.rect.left}%`, + top: `${d.rect.top}%`, + width: `${d.rect.width}%`, + height: `${d.rect.height}%`, + }} + onMouseDown={(e) => startPaneDividerDrag(e, active, d)} + /> + ); + })} </section> - {showUserChat && <div className="divider-h" onMouseDown={startChatDrag} />} + {showUserChat && chatPaneState === "normal" && ( + <div className="divider-h" onMouseDown={startChatDrag} /> + )} {/* App-user chat: the human's own send/receive thread + composer. - Hidden via View > Show User Chat (native menu checkbox). Clicking - the history jumps focus to the composer input below — the - history itself has nothing to focus (nothing to type into), so - without this the border-on-focus feedback here was invisible in - normal use. */} + Hidden via View > Show User Chat (native menu checkbox). + min/max header buttons toggle chatPaneState between normal, + minimized (history collapsed away — just the composer row, + fixing the "my own messages show twice" fatigue real users + reported once the room already shows everything), and + maximized (fills the whole content area, hiding the team + room/agent panes — a focused 1:1 view). Clicking the history + jumps focus to the composer input below — the history itself + has nothing to focus (nothing to type into), so without this + the border-on-focus feedback here was invisible in normal use. */} <div - className="appuser-chat" - ref={chatRef} - style={{ height: chatHeight }} + className={`appuser-chat-wrap state-${chatPaneState}`} + style={chatPaneState === "normal" ? { height: chatHeight } : undefined} hidden={!showUserChat} - onClick={() => composerInputRef.current?.focus()} > - {myThread.map((m) => ( - <div className={m.from === appUser ? "chat-line out" : "chat-line in"} key={m.id}> - <span className="chat-time">{m.created_at.slice(11, 19)}</span> - <span className="chat-peer"> - {m.from === appUser - ? t("chat.peer.to", { to: m.to }) - : t("chat.peer.from", { from: m.from })} - </span> - <span className="chat-body">{m.body}</span> - </div> - ))} - {appUser && myThread.length === 0 && ( - <div className="empty">{t("chat.emptyState.withUser", { appUser })}</div> - )} - {!appUser && team && ( - <div className="empty"> - {t("chat.emptyState.noUser")} - <button className="link" onClick={() => setModal({ kind: "appuser" })}> - {t("chat.emptyState.addOne")} + <div + className="appuser-chat-header" + data-tauri-drag-region + onContextMenu={(e) => { + e.preventDefault(); + e.stopPropagation(); + closeAllMenus(); + setChatMenu({ x: e.clientX, y: e.clientY }); + }} + > + <span className="appuser-chat-title">{t("chat.title")}</span> + <div className="appuser-chat-controls"> + <button + className="chat-ctrl-btn" + title={chatPaneState === "minimized" ? t("chat.restore") : t("chat.minimize")} + onClick={toggleChatMinimized} + > + {chatPaneState === "minimized" ? <RectangleHorizontal size={14} /> : <Minus size={14} />} + </button> + <button + className="chat-ctrl-btn" + title={chatPaneState === "maximized" ? t("chat.restore") : t("chat.maximize")} + onClick={toggleChatMaximized} + > + {chatPaneState === "maximized" ? <Minimize2 size={14} /> : <Maximize2 size={14} />} </button> </div> - )} - </div> + </div> - <footer className="composer" hidden={!showUserChat}> - {appUser ? ( - <> - <span className="as"> - {t("composer.asPrefix")} <b>{appUser}</b> - </span> - <select value={target} onChange={(e) => setTarget(e.target.value)}> - <option value="">{t("composer.targetPlaceholder")}</option> - {others.map((m) => ( - <option key={m.name} value={m.name}> - {m.name} - </option> - ))} - </select> - <input - ref={composerInputRef} - value={draft} - placeholder={t("composer.messagePlaceholder")} - onChange={(e) => setDraft(e.target.value)} - onKeyDown={(e) => isSubmitEnter(e) && send()} - {...imeCompositionProps} - /> - <button onClick={send} disabled={!draft.trim() || !target}> - {t("composer.sendButton")} - </button> - </> - ) : ( - <span className="as">{t("composer.noAppUser")}</span> + {chatPaneState !== "minimized" && ( + <div className="appuser-chat" ref={chatRef} onClick={() => composerInputRef.current?.focus()}> + {myThread.map((m) => ( + <div className={m.from === appUser ? "chat-line out" : "chat-line in"} key={m.id}> + <span className="chat-time">{formatMessageTime(m.created_at, effectiveTimeZone)}</span> + <span className="chat-peer"> + {m.from === appUser + ? t("chat.peer.to", { to: m.to }) + : t("chat.peer.from", { from: m.from })} + </span> + <span className="chat-body">{m.body}</span> + </div> + ))} + {appUser && myThread.length === 0 && ( + <div className="empty">{t("chat.emptyState.withUser", { appUser })}</div> + )} + {!appUser && team && ( + <div className="empty"> + {t("chat.emptyState.noUser")} + <button className="link" onClick={() => setModal({ kind: "appuser", auto: false })}> + {t("chat.emptyState.addOne")} + </button> + </div> + )} + </div> )} - </footer> + + <footer className="composer"> + {appUser ? ( + <> + <span className="as"> + {/* Word order around the name varies by language (English + "as X" puts it after; Japanese "X として" puts it + before) — asLabel is the whole template with a literal + "{{appUser}}" placeholder, split here so only the name + itself renders bold, wherever the translation puts it. */} + {(() => { + const [before, after] = t("composer.asLabel").split("{{appUser}}"); + return ( + <> + {before} + <b>{appUser}</b> + {after} + </> + ); + })()} + </span> + <select value={target} onChange={(e) => setTarget(e.target.value)}> + <option value="">{t("composer.targetPlaceholder")}</option> + {others.map((m) => ( + <option key={m.name} value={m.name}> + {m.name} + </option> + ))} + </select> + <input + ref={composerInputRef} + value={draft} + placeholder={t("composer.messagePlaceholder")} + onChange={(e) => setDraft(e.target.value)} + onKeyDown={(e) => isSubmitEnter(e) && send()} + {...imeCompositionProps} + /> + <button onClick={send} disabled={!draft.trim() || !target}> + {t("composer.sendButton")} + </button> + </> + ) : ( + <span className="as">{t("composer.noAppUser")}</span> + )} + </footer> + </div> </main> </div> @@ -1486,7 +2032,18 @@ export default function App() { /> )} {modal?.kind === "appuser" && ( - <AppUserModal onAdd={onAddAppUser} onClose={() => setModal(null)} browseDir={browseDir} /> + <AppUserModal + onAdd={onAddAppUser} + onClose={() => { + // Dismissing the auto-prompt (vs. the "Add one" link's manual + // open) means the user doesn't want to add one right now — an + // app-user is always reachable from the chat link, so don't + // re-ask on every future team switch (see SUPPRESS_APPUSER_PROMPT_KEY). + if (modal.auto) localStorage.setItem(SUPPRESS_APPUSER_PROMPT_KEY, "true"); + setModal(null); + }} + browseDir={browseDir} + /> )} {modal?.kind === "agent" && ( <AgentModal @@ -1510,7 +2067,15 @@ export default function App() { onClose={() => setModal(null)} /> )} - {modal?.kind === "settings" && <SettingsModal onClose={() => setModal(null)} />} + {modal?.kind === "settings" && ( + <SettingsModal + onClose={() => setModal(null)} + terminalFontSize={terminalFontSize} + onTerminalFontSizeChange={setTerminalFontSize} + timezone={timezone} + onTimezoneChange={setTimezone} + /> + )} {modal?.kind === "closeWindow" && (() => { const win = windows.find((w) => w.id === modal.windowId); @@ -1697,6 +2262,40 @@ export default function App() { ); })()} + {roomMenu && ( + <div className="ctx-menu" style={{ left: roomMenu.x, top: roomMenu.y }} onClick={(e) => e.stopPropagation()}> + <button + onClick={() => { + setShowTeamRoom(false); + setRoomMenu(null); + // Keeps the native View > Show Team Room checkbox in sync — + // it's not the surface that changed this, so Rust's own copy + // of the toggle state would otherwise go stale. + void invoke("set_team_room_visible", { visible: false }); + }} + > + {t("ctxMenu.room.hide")} + </button> + </div> + )} + + {chatMenu && ( + <div className="ctx-menu" style={{ left: chatMenu.x, top: chatMenu.y }} onClick={(e) => e.stopPropagation()}> + <button + onClick={() => { + setShowUserChat(false); + setChatMenu(null); + // Keeps the native View > Show User Chat checkbox in sync — + // it's not the surface that changed this, so Rust's own copy + // of the toggle state would otherwise go stale. + void invoke("set_user_chat_visible", { visible: false }); + }} + > + {t("ctxMenu.chat.hide")} + </button> + </div> + )} + {windowMenu && (() => { const layouts: PaneLayout[] = ["vertical", "horizontal", "tile"]; diff --git a/app/src/TerminalPane.tsx b/app/src/TerminalPane.tsx index 24903ba9..1f5ee41a 100644 --- a/app/src/TerminalPane.tsx +++ b/app/src/TerminalPane.tsx @@ -5,6 +5,7 @@ import { FitAddon } from "@xterm/addon-fit"; import "@xterm/xterm/css/xterm.css"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; +import { createWriteBatcher } from "./writeBatcher"; type Props = { /** Stable session id; also the key the backend stores the PTY under. */ @@ -12,6 +13,11 @@ type Props = { cmd: string; args?: string[]; cwd?: string; + fontSize?: number; + onAgentState?: (id: string, state: "idle" | "working" | "blocked" | "unknown") => void; + /** Reported on every fit — the pane's current cell size in CSS px, so a + * divider drag elsewhere can snap to whole terminal rows/cols. */ + onCellSize?: (widthPx: number, heightPx: number) => void; }; function b64ToBytes(b64: string): Uint8Array { @@ -25,19 +31,29 @@ function b64ToBytes(b64: string): Uint8Array { * One embedded agent terminal: an xterm.js view bound to a backend PTY session. * Output streams in via `pty-output` events; keystrokes go back via `pty_write`. */ -export function TerminalPane({ id, cmd, args = [], cwd }: Props) { +export function TerminalPane({ id, cmd, args = [], cwd, fontSize = 12, onAgentState, onCellSize }: Props) { const ref = useRef<HTMLDivElement>(null); + // Live handles to the current terminal/fit addon, for the font-size effect + // below to reach — that effect must NOT be a dependency of the main effect + // (a fontSize change would otherwise kill and respawn the PTY, losing the + // running process). + const termRef = useRef<Terminal | null>(null); + const fitRef = useRef<FitAddon | null>(null); + const idRef = useRef(id); + idRef.current = id; const { t } = useTranslation(); useEffect(() => { let disposed = false; const term = new Terminal({ - fontSize: 12, + fontSize, fontFamily: "Menlo, Monaco, 'Courier New', monospace", cursorBlink: true, theme: { background: "#0b0e14", foreground: "#c5c8c6" }, }); + termRef.current = term; const fit = new FitAddon(); + fitRef.current = fit; term.loadAddon(fit); term.open(ref.current!); @@ -62,22 +78,65 @@ export function TerminalPane({ id, cmd, args = [], cwd }: Props) { lastCols = term.cols; void invoke("pty_resize", { id, rows: term.rows, cols: term.cols }); } + // Every pane uses the same fixed font today, so any one of them + // reporting its cell size is representative of them all — a divider + // drag doesn't need to know which specific panes it's between. + onCellSize?.(el.offsetWidth / term.cols, el.offsetHeight / term.rows); }; + // Coalesce PTY output into at most one term.write() per animation frame + // instead of one per backend event. The Rust reader thread emits an + // event per raw PTY read (unbatched, no debounce) — a chatty CLI (issue + // #383: Codex's title-escape-driven spinner churns very frequently) + // can fire far more of these than the browser can usefully paint + // between frames. Unverified hypothesis behind this change: each + // term.write() resets xterm's cursor blink phase, so writing many + // times within a single frame is a plausible source of the reported + // visible flicker/jitter — batching bounds that to once per frame + // regardless of how bursty the backend is. See writeBatcher.ts for why + // this isn't just a bare requestAnimationFrame (it stalls in a + // backgrounded/occluded webview, and agmsg mounts panes while hidden). + const writeBatcher = createWriteBatcher({ onFlush: (data) => term.write(data) }); + const unlisteners: Array<() => void> = []; (async () => { // Register listeners BEFORE spawning so no early output is missed. - unlisteners.push( - await listen<{ id: string; b64: string }>("pty-output", (e) => { - if (e.payload.id === id) term.write(b64ToBytes(e.payload.b64)); - }), - ); - unlisteners.push( - await listen<{ id: string }>("pty-exit", (e) => { - if (e.payload.id === id) term.write(`\r\n\x1b[90m${t("terminal.processExited")}\x1b[0m\r\n`); - }), - ); - if (disposed) return; + // `listen()` is async — if this effect's cleanup runs while one of + // these awaits is still in flight (a fast unmount/remount, or React + // re-running the effect), the listener resolves into a component + // that's already torn down. Each registration below checks `disposed` + // right after its own await and, if already torn down, unregisters + // itself immediately instead of joining `unlisteners` — otherwise the + // Tauri listener would keep firing this closure's stale term/ + // writeBatcher forever (a real listener leak), and — before + // writeBatcher.dispose() became permanent (see writeBatcher.ts) — + // could even resurrect its scheduling after teardown. The `disposed` + // check inside each callback body is defense in depth for an event + // already queued at the moment unlisten() runs. + const unlistenOutput = await listen<{ id: string; b64: string }>("pty-output", (e) => { + if (disposed) return; + if (e.payload.id === id) writeBatcher.push(b64ToBytes(e.payload.b64)); + }); + if (disposed) { + unlistenOutput(); + return; + } + unlisteners.push(unlistenOutput); + + const unlistenExit = await listen<{ id: string }>("pty-exit", (e) => { + if (disposed) return; + if (e.payload.id !== id) return; + // Flush synchronously first — any output still waiting for its + // batched write must land before the exit banner, or the banner + // could render above the process's own final lines. + writeBatcher.flushNow(); + term.write(`\r\n\x1b[90m${t("terminal.processExited")}\x1b[0m\r\n`); + }); + if (disposed) { + unlistenExit(); + return; + } + unlisteners.push(unlistenExit); term.onData((data) => void invoke("pty_write", { id, data })); fitNow(); // size the PTY to the pane if it's already laid out try { @@ -88,7 +147,11 @@ export function TerminalPane({ id, cmd, args = [], cwd }: Props) { // wrong. Write the failure straight into the terminal — it's already // the visible surface for this pane, no extra UI needed. term.write(`\r\n\x1b[91m${t("terminal.failedToStart", { cmd, error: String(err) })}\x1b[0m\r\n`); + return; } + void invoke<"idle" | "working" | "blocked" | "unknown">("agent_state", { id }) + .then((state) => onAgentState?.(id, state)) + .catch(() => {}); })(); // Re-fit whenever the pane's box changes — covers initial layout, switching @@ -110,12 +173,45 @@ export function TerminalPane({ id, cmd, args = [], cwd }: Props) { return () => { disposed = true; if (resizeTimer) clearTimeout(resizeTimer); + writeBatcher.dispose(); ro.disconnect(); unlisteners.forEach((u) => u()); void invoke("pty_kill", { id }); term.dispose(); + termRef.current = null; + fitRef.current = null; }; - }, [id, cmd, cwd]); + }, [id, cmd, cwd, onAgentState, onCellSize]); + + // Apply a fontSize change live, without recreating the terminal (which + // would kill and respawn the PTY). Skips its very first run — at that + // point termRef was *just* created above with this same fontSize value + // (passed to the Terminal constructor), so re-fitting would be a no-op + // re-resize racing the pty_spawn call still in flight in the effect above. + const skipInitialFontSizeEffect = useRef(true); + useEffect(() => { + if (skipInitialFontSizeEffect.current) { + skipInitialFontSizeEffect.current = false; + return; + } + const term = termRef.current; + const fit = fitRef.current; + const el = ref.current; + if (!term || !fit || !el || el.offsetWidth === 0 || el.offsetHeight === 0) return; + term.options.fontSize = fontSize; + try { + fit.fit(); + } catch { + return; + } + void invoke("pty_resize", { id: idRef.current, rows: term.rows, cols: term.cols }); + // A font size change resizes xterm's internal cell geometry without + // resizing the .term-pane container itself, so the ResizeObserver in + // the main effect above never fires for it — re-report cell size here, + // or divider snap/gap sizing (see onCellSize's doc comment) silently + // stays pinned to whatever font was active on last container resize. + onCellSize?.(el.offsetWidth / term.cols, el.offsetHeight / term.rows); + }, [fontSize, onCellSize]); return <div className="term-pane" ref={ref} />; } diff --git a/app/src/agentStatus.test.ts b/app/src/agentStatus.test.ts new file mode 100644 index 00000000..9f898c67 --- /dev/null +++ b/app/src/agentStatus.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { aggregateTeamStatus, applyStateChange, type PaneStatus, type PaneStatusMap } from "./agentStatus"; + +const status = (state: PaneStatus["state"]): PaneStatus => ({ state }); + +describe("applyStateChange", () => { + it("sets the new state for a pane", () => { + const result = applyStateChange({}, "pane", "working"); + expect(result.pane).toEqual({ state: "working" }); + }); + + it("returns the same map reference when the state didn't change", () => { + const initial: PaneStatusMap = { pane: status("working") }; + const result = applyStateChange(initial, "pane", "working"); + expect(result).toBe(initial); + }); + + it("updates only the given pane", () => { + const initial: PaneStatusMap = { a: status("working"), b: status("idle") }; + const result = applyStateChange(initial, "a", "blocked"); + expect(result.a).toEqual({ state: "blocked" }); + expect(result.b).toEqual({ state: "idle" }); + }); +}); + +describe("aggregateTeamStatus", () => { + it("returns unknown for an empty set", () => { + expect(aggregateTeamStatus([])).toBe("unknown"); + }); + + it("lets one blocked pane beat every other state", () => { + expect(aggregateTeamStatus([status("working"), status("idle"), status("blocked")])).toBe("blocked"); + }); + + it("prioritizes working over idle and unknown", () => { + expect(aggregateTeamStatus([status("idle"), status("unknown"), status("working")])).toBe("working"); + }); +}); diff --git a/app/src/agentStatus.ts b/app/src/agentStatus.ts new file mode 100644 index 00000000..d8252a98 --- /dev/null +++ b/app/src/agentStatus.ts @@ -0,0 +1,21 @@ +export type RawState = "idle" | "working" | "blocked" | "unknown"; +export type PaneStatus = { state: RawState }; +export type PaneStatusMap = Record<string, PaneStatus>; + +export function applyStateChange(map: PaneStatusMap, paneId: string, newState: RawState): PaneStatusMap { + if (map[paneId]?.state === newState) return map; + return { ...map, [paneId]: { state: newState } }; +} + +export function aggregateTeamStatus(statuses: PaneStatus[]): RawState { + const priority: Record<RawState, number> = { + blocked: 3, + working: 2, + idle: 1, + unknown: 0, + }; + return statuses.reduce<RawState>( + (aggregate, status) => (priority[status.state] > priority[aggregate] ? status.state : aggregate), + "unknown", + ); +} diff --git a/app/src/i18n/locales/de.json b/app/src/i18n/locales/de.json index 82f68da1..a6652ea9 100644 --- a/app/src/i18n/locales/de.json +++ b/app/src/i18n/locales/de.json @@ -20,12 +20,19 @@ }, "sidebar": { "logoAlt": "agmsg", + "collapse": "Seitenleiste einklappen", + "expand": "Seitenleiste ausklappen", "newMenu": { "trigger": "+ Neu ▾", "team": "Team…", "agent": "Agent…" }, + "team": { + "title": "Team", + "new": "Neues Team" + }, "title": "Agenten", + "newAgent": "Neuer Agent", "filter": { "all": "alle", "none": "keine" @@ -35,7 +42,7 @@ "titleRunning": "läuft bereits — klicken zum Fokussieren", "titleSpawn": "in einem PTY-Bereich starten", "noTypes": "—", - "emptyState": "Noch keine Agenten. + Neu → Agent verwenden." + "emptyState": "Noch keine Agenten. Mit der +-Schaltfläche oben einen hinzufügen." }, "user": { "title": "app-user in {{team}}" @@ -60,6 +67,10 @@ "swapTitle": "Klicken, dann auf ein anderes Panel in diesem Tab klicken, um die Positionen zu tauschen" }, "chat": { + "title": "Chat", + "minimize": "Minimieren", + "maximize": "Maximieren", + "restore": "Wiederherstellen", "peer": { "to": "→ {{to}}", "from": "{{from}} →" @@ -71,7 +82,7 @@ } }, "composer": { - "asPrefix": "als", + "asLabel": "als {{appUser}}", "targetPlaceholder": "an…", "messagePlaceholder": "Nachricht", "sendButton": "senden", @@ -148,6 +159,12 @@ "layoutVertical": "Vertikal", "layoutHorizontal": "Horizontal", "layoutTile": "Kacheln" + }, + "room": { + "hide": "Team-Raum ausblenden" + }, + "chat": { + "hide": "Meinen Chat ausblenden" } }, "terminal": { @@ -158,7 +175,14 @@ "label": "Sprache" }, "settings": { - "title": "Einstellungen" + "title": "Einstellungen", + "terminalFontSize": { + "label": "Terminal-Schriftgröße" + }, + "timezone": { + "label": "Zeitzone", + "auto": "Automatisch ({{zone}})" + } }, "nativeMenu": { "about": "Über {{name}}", @@ -175,6 +199,7 @@ "paste": "Einsetzen", "selectAll": "Alles auswählen", "viewMenu": "Darstellung", + "showTeamRoom": "Team-Raum anzeigen", "showUserChat": "Meinen Chat anzeigen", "paneLayout": "Bereichslayout", "paneLayoutVertical": "Vertikal", @@ -196,5 +221,8 @@ "updateFailed": "Aktualisierung fehlgeschlagen: {{error}}", "upToDate": "Sie verwenden die neueste Version.", "noUpdatesTitle": "Keine Updates" + }, + "stage": { + "emptyHint": "Klicke links auf ein Mitglied, um einen Agenten zu starten." } } diff --git a/app/src/i18n/locales/en.json b/app/src/i18n/locales/en.json index 9305bee1..00c581a9 100644 --- a/app/src/i18n/locales/en.json +++ b/app/src/i18n/locales/en.json @@ -7,7 +7,14 @@ "projectDirLabel": "Project dir" }, "settings": { - "title": "Settings" + "title": "Settings", + "terminalFontSize": { + "label": "Terminal font size" + }, + "timezone": { + "label": "Timezone", + "auto": "Auto ({{zone}})" + } }, "startupError": { "installing": "Installing agmsg…", @@ -23,12 +30,19 @@ }, "sidebar": { "logoAlt": "agmsg", + "collapse": "Collapse sidebar", + "expand": "Expand sidebar", "newMenu": { "trigger": "+ New ▾", "team": "Team…", "agent": "Agent…" }, + "team": { + "title": "Team", + "new": "New team" + }, "title": "Agents", + "newAgent": "New agent", "filter": { "all": "all", "none": "none" @@ -38,7 +52,7 @@ "titleRunning": "already running — click to focus", "titleSpawn": "spawn in a PTY pane", "noTypes": "—", - "emptyState": "No agents yet. Use + New → Agent." + "emptyState": "No agents yet. Use the + button above to add one." }, "user": { "title": "app-user in {{team}}" @@ -63,6 +77,10 @@ "swapTitle": "Click, then click another pane in this tab to swap positions" }, "chat": { + "title": "Chat", + "minimize": "Minimize", + "maximize": "Maximize", + "restore": "Restore", "peer": { "to": "→ {{to}}", "from": "{{from}} →" @@ -74,7 +92,7 @@ } }, "composer": { - "asPrefix": "as", + "asLabel": "as {{appUser}}", "targetPlaceholder": "to…", "messagePlaceholder": "message", "sendButton": "send", @@ -151,6 +169,12 @@ "layoutVertical": "Vertical", "layoutHorizontal": "Horizontal", "layoutTile": "Tile" + }, + "room": { + "hide": "Hide Team Room" + }, + "chat": { + "hide": "Hide User Chat" } }, "terminal": { @@ -175,6 +199,7 @@ "paste": "Paste", "selectAll": "Select All", "viewMenu": "View", + "showTeamRoom": "Show Team Room", "showUserChat": "Show User Chat", "paneLayout": "Pane Layout", "paneLayoutVertical": "Vertical", @@ -196,5 +221,8 @@ "updateFailed": "Update failed: {{error}}", "upToDate": "You're on the latest version.", "noUpdatesTitle": "No Updates" + }, + "stage": { + "emptyHint": "Click a member on the left to start an agent." } } diff --git a/app/src/i18n/locales/es.json b/app/src/i18n/locales/es.json index 09ab4552..4f7649f3 100644 --- a/app/src/i18n/locales/es.json +++ b/app/src/i18n/locales/es.json @@ -20,12 +20,19 @@ }, "sidebar": { "logoAlt": "agmsg", + "collapse": "Contraer barra lateral", + "expand": "Expandir barra lateral", "newMenu": { "trigger": "+ Nuevo ▾", "team": "Equipo…", "agent": "Agente…" }, + "team": { + "title": "Equipo", + "new": "Nuevo equipo" + }, "title": "Agentes", + "newAgent": "Nuevo agente", "filter": { "all": "todos", "none": "ninguno" @@ -35,7 +42,7 @@ "titleRunning": "ya está en ejecución — haga clic para enfocar", "titleSpawn": "iniciar en un panel PTY", "noTypes": "—", - "emptyState": "Aún no hay agentes. Use + Nuevo → Agente." + "emptyState": "Aún no hay agentes. Use el botón + de arriba para añadir uno." }, "user": { "title": "app-user en {{team}}" @@ -60,6 +67,10 @@ "swapTitle": "Haz clic y luego haz clic en otro panel de esta pestaña para intercambiar posiciones" }, "chat": { + "title": "Chat", + "minimize": "Minimizar", + "maximize": "Maximizar", + "restore": "Restaurar", "peer": { "to": "→ {{to}}", "from": "{{from}} →" @@ -71,7 +82,7 @@ } }, "composer": { - "asPrefix": "como", + "asLabel": "como {{appUser}}", "targetPlaceholder": "para…", "messagePlaceholder": "mensaje", "sendButton": "enviar", @@ -148,6 +159,12 @@ "layoutVertical": "Vertical", "layoutHorizontal": "Horizontal", "layoutTile": "Mosaico" + }, + "room": { + "hide": "Ocultar sala de equipo" + }, + "chat": { + "hide": "Ocultar mi chat" } }, "terminal": { @@ -158,7 +175,14 @@ "label": "Idioma" }, "settings": { - "title": "Configuración" + "title": "Configuración", + "terminalFontSize": { + "label": "Tamaño de fuente de la terminal" + }, + "timezone": { + "label": "Zona horaria", + "auto": "Automático ({{zone}})" + } }, "nativeMenu": { "about": "Acerca de {{name}}", @@ -175,6 +199,7 @@ "paste": "Pegar", "selectAll": "Seleccionar todo", "viewMenu": "Ver", + "showTeamRoom": "Mostrar sala de equipo", "showUserChat": "Mostrar mi chat", "paneLayout": "Diseño de paneles", "paneLayoutVertical": "Vertical", @@ -196,5 +221,8 @@ "updateFailed": "Error al actualizar: {{error}}", "upToDate": "Tienes la última versión.", "noUpdatesTitle": "Sin actualizaciones" + }, + "stage": { + "emptyHint": "Haz clic en un miembro de la izquierda para iniciar un agente." } } diff --git a/app/src/i18n/locales/fr.json b/app/src/i18n/locales/fr.json index 38bd9c17..46198cc4 100644 --- a/app/src/i18n/locales/fr.json +++ b/app/src/i18n/locales/fr.json @@ -20,12 +20,19 @@ }, "sidebar": { "logoAlt": "agmsg", + "collapse": "Réduire la barre latérale", + "expand": "Développer la barre latérale", "newMenu": { "trigger": "+ Nouveau ▾", "team": "Équipe…", "agent": "Agent…" }, + "team": { + "title": "Équipe", + "new": "Nouvelle équipe" + }, "title": "Agents", + "newAgent": "Nouvel agent", "filter": { "all": "tous", "none": "aucun" @@ -35,7 +42,7 @@ "titleRunning": "déjà en cours — cliquer pour afficher", "titleSpawn": "démarrer dans un panneau PTY", "noTypes": "—", - "emptyState": "Aucun agent pour l'instant. Utilisez + Nouveau → Agent." + "emptyState": "Aucun agent pour l'instant. Utilisez le bouton + ci-dessus pour en ajouter un." }, "user": { "title": "app-user dans {{team}}" @@ -60,6 +67,10 @@ "swapTitle": "Cliquez, puis cliquez sur un autre panneau de cet onglet pour échanger les positions" }, "chat": { + "title": "Discussion", + "minimize": "Réduire", + "maximize": "Agrandir", + "restore": "Restaurer", "peer": { "to": "→ {{to}}", "from": "{{from}} →" @@ -71,7 +82,7 @@ } }, "composer": { - "asPrefix": "en tant que", + "asLabel": "en tant que {{appUser}}", "targetPlaceholder": "à…", "messagePlaceholder": "message", "sendButton": "envoyer", @@ -148,6 +159,12 @@ "layoutVertical": "Vertical", "layoutHorizontal": "Horizontal", "layoutTile": "Mosaïque" + }, + "room": { + "hide": "Masquer la salle d'équipe" + }, + "chat": { + "hide": "Masquer mon chat" } }, "terminal": { @@ -158,7 +175,14 @@ "label": "Langue" }, "settings": { - "title": "Paramètres" + "title": "Paramètres", + "terminalFontSize": { + "label": "Taille de police du terminal" + }, + "timezone": { + "label": "Fuseau horaire", + "auto": "Auto ({{zone}})" + } }, "nativeMenu": { "about": "À propos de {{name}}", @@ -175,6 +199,7 @@ "paste": "Coller", "selectAll": "Tout sélectionner", "viewMenu": "Présentation", + "showTeamRoom": "Afficher la salle d'équipe", "showUserChat": "Afficher mon chat", "paneLayout": "Disposition des volets", "paneLayoutVertical": "Vertical", @@ -196,5 +221,8 @@ "updateFailed": "Échec de la mise à jour : {{error}}", "upToDate": "Vous avez la dernière version.", "noUpdatesTitle": "Aucune mise à jour" + }, + "stage": { + "emptyHint": "Cliquez sur un membre à gauche pour démarrer un agent." } } diff --git a/app/src/i18n/locales/ja.json b/app/src/i18n/locales/ja.json index 607efee7..587e63f1 100644 --- a/app/src/i18n/locales/ja.json +++ b/app/src/i18n/locales/ja.json @@ -20,12 +20,19 @@ }, "sidebar": { "logoAlt": "agmsg", + "collapse": "サイドバーを折りたたむ", + "expand": "サイドバーを展開", "newMenu": { "trigger": "+ 新規 ▾", "team": "チーム…", "agent": "エージェント…" }, + "team": { + "title": "チーム", + "new": "新規チーム" + }, "title": "エージェント", + "newAgent": "新規エージェント", "filter": { "all": "すべて", "none": "なし" @@ -35,7 +42,7 @@ "titleRunning": "実行中 — クリックしてフォーカス", "titleSpawn": "PTYペインで起動", "noTypes": "—", - "emptyState": "エージェントはまだありません。+ 新規 → エージェント から追加してください。" + "emptyState": "エージェントはまだありません。上の+ボタンから追加してください。" }, "user": { "title": "{{team}} のapp-user" @@ -60,6 +67,10 @@ "swapTitle": "クリックしてから、このタブ内の別のペインをクリックすると位置を入れ替えます" }, "chat": { + "title": "チャット", + "minimize": "最小化", + "maximize": "最大化", + "restore": "元に戻す", "peer": { "to": "→ {{to}}", "from": "{{from}} →" @@ -71,7 +82,7 @@ } }, "composer": { - "asPrefix": "として", + "asLabel": "{{appUser}} として", "targetPlaceholder": "宛先…", "messagePlaceholder": "メッセージ", "sendButton": "送信", @@ -148,6 +159,12 @@ "layoutVertical": "縦", "layoutHorizontal": "横", "layoutTile": "タイル" + }, + "room": { + "hide": "チームルームを非表示" + }, + "chat": { + "hide": "自分のチャットを非表示" } }, "terminal": { @@ -158,7 +175,14 @@ "label": "言語" }, "settings": { - "title": "設定" + "title": "設定", + "terminalFontSize": { + "label": "ターミナルのフォントサイズ" + }, + "timezone": { + "label": "タイムゾーン", + "auto": "自動 ({{zone}})" + } }, "nativeMenu": { "about": "{{name}} について", @@ -175,6 +199,7 @@ "paste": "ペースト", "selectAll": "すべてを選択", "viewMenu": "表示", + "showTeamRoom": "チームルームを表示", "showUserChat": "自分のチャットを表示", "paneLayout": "ペインレイアウト", "paneLayoutVertical": "縦", @@ -196,5 +221,8 @@ "updateFailed": "アップデートに失敗しました: {{error}}", "upToDate": "最新バージョンです。", "noUpdatesTitle": "アップデートなし" + }, + "stage": { + "emptyHint": "左のメンバーをクリックしてエージェントを起動してください。" } } diff --git a/app/src/i18n/locales/ko.json b/app/src/i18n/locales/ko.json index 4b92749b..90a86390 100644 --- a/app/src/i18n/locales/ko.json +++ b/app/src/i18n/locales/ko.json @@ -20,12 +20,19 @@ }, "sidebar": { "logoAlt": "agmsg", + "collapse": "사이드바 접기", + "expand": "사이드바 펼치기", "newMenu": { "trigger": "+ 새로 만들기 ▾", "team": "팀…", "agent": "에이전트…" }, + "team": { + "title": "팀", + "new": "새 팀" + }, "title": "에이전트", + "newAgent": "새 에이전트", "filter": { "all": "전체", "none": "없음" @@ -35,7 +42,7 @@ "titleRunning": "실행 중 — 클릭하면 포커스", "titleSpawn": "PTY 창에서 실행", "noTypes": "—", - "emptyState": "아직 에이전트가 없습니다. + 새로 만들기 → 에이전트를 사용하세요." + "emptyState": "아직 에이전트가 없습니다. 위의 + 버튼을 사용해 추가하세요." }, "user": { "title": "{{team}}의 app-user" @@ -60,6 +67,10 @@ "swapTitle": "클릭한 다음 이 탭의 다른 패널을 클릭하면 위치가 바뀝니다" }, "chat": { + "title": "채팅", + "minimize": "최소화", + "maximize": "최대화", + "restore": "복원", "peer": { "to": "→ {{to}}", "from": "{{from}} →" @@ -71,7 +82,7 @@ } }, "composer": { - "asPrefix": "발신", + "asLabel": "{{appUser}}(으)로", "targetPlaceholder": "받는 사람…", "messagePlaceholder": "메시지", "sendButton": "보내기", @@ -148,6 +159,12 @@ "layoutVertical": "세로", "layoutHorizontal": "가로", "layoutTile": "타일" + }, + "room": { + "hide": "팀 룸 숨기기" + }, + "chat": { + "hide": "내 채팅 숨기기" } }, "terminal": { @@ -158,7 +175,14 @@ "label": "언어" }, "settings": { - "title": "설정" + "title": "설정", + "terminalFontSize": { + "label": "터미널 글꼴 크기" + }, + "timezone": { + "label": "시간대", + "auto": "자동 ({{zone}})" + } }, "nativeMenu": { "about": "{{name}} 정보", @@ -175,6 +199,7 @@ "paste": "붙여넣기", "selectAll": "모두 선택", "viewMenu": "보기", + "showTeamRoom": "팀 룸 표시", "showUserChat": "내 채팅 표시", "paneLayout": "패널 레이아웃", "paneLayoutVertical": "세로", @@ -196,5 +221,8 @@ "updateFailed": "업데이트 실패: {{error}}", "upToDate": "최신 버전입니다.", "noUpdatesTitle": "업데이트 없음" + }, + "stage": { + "emptyHint": "왼쪽의 멤버를 클릭하여 에이전트를 시작하세요." } } diff --git a/app/src/i18n/locales/pt-BR.json b/app/src/i18n/locales/pt-BR.json index b258f69c..ab10b4ed 100644 --- a/app/src/i18n/locales/pt-BR.json +++ b/app/src/i18n/locales/pt-BR.json @@ -20,12 +20,19 @@ }, "sidebar": { "logoAlt": "agmsg", + "collapse": "Recolher barra lateral", + "expand": "Expandir barra lateral", "newMenu": { "trigger": "+ Novo ▾", "team": "Equipe…", "agent": "Agente…" }, + "team": { + "title": "Equipe", + "new": "Nova equipe" + }, "title": "Agentes", + "newAgent": "Novo agente", "filter": { "all": "todos", "none": "nenhum" @@ -35,7 +42,7 @@ "titleRunning": "já em execução — clique para focar", "titleSpawn": "iniciar em um painel PTY", "noTypes": "—", - "emptyState": "Nenhum agente ainda. Use + Novo → Agente." + "emptyState": "Nenhum agente ainda. Use o botão + acima para adicionar um." }, "user": { "title": "app-user em {{team}}" @@ -60,6 +67,10 @@ "swapTitle": "Clique e depois clique em outro painel desta aba para trocar as posições" }, "chat": { + "title": "Bate-papo", + "minimize": "Minimizar", + "maximize": "Maximizar", + "restore": "Restaurar", "peer": { "to": "→ {{to}}", "from": "{{from}} →" @@ -71,7 +82,7 @@ } }, "composer": { - "asPrefix": "como", + "asLabel": "como {{appUser}}", "targetPlaceholder": "para…", "messagePlaceholder": "mensagem", "sendButton": "enviar", @@ -148,6 +159,12 @@ "layoutVertical": "Vertical", "layoutHorizontal": "Horizontal", "layoutTile": "Mosaico" + }, + "room": { + "hide": "Ocultar sala da equipe" + }, + "chat": { + "hide": "Ocultar meu chat" } }, "terminal": { @@ -158,7 +175,14 @@ "label": "Idioma" }, "settings": { - "title": "Configurações" + "title": "Configurações", + "terminalFontSize": { + "label": "Tamanho da fonte do terminal" + }, + "timezone": { + "label": "Fuso horário", + "auto": "Automático ({{zone}})" + } }, "nativeMenu": { "about": "Sobre o {{name}}", @@ -175,6 +199,7 @@ "paste": "Colar", "selectAll": "Selecionar tudo", "viewMenu": "Visualizar", + "showTeamRoom": "Mostrar sala da equipe", "showUserChat": "Mostrar meu chat", "paneLayout": "Layout dos painéis", "paneLayoutVertical": "Vertical", @@ -196,5 +221,8 @@ "updateFailed": "Falha na atualização: {{error}}", "upToDate": "Você está na versão mais recente.", "noUpdatesTitle": "Sem atualizações" + }, + "stage": { + "emptyHint": "Clique em um membro à esquerda para iniciar um agente." } } diff --git a/app/src/i18n/locales/zh-CN.json b/app/src/i18n/locales/zh-CN.json index 0e4f9e00..fd603451 100644 --- a/app/src/i18n/locales/zh-CN.json +++ b/app/src/i18n/locales/zh-CN.json @@ -20,12 +20,19 @@ }, "sidebar": { "logoAlt": "agmsg", + "collapse": "折叠侧边栏", + "expand": "展开侧边栏", "newMenu": { "trigger": "+ 新建 ▾", "team": "团队…", "agent": "智能体…" }, + "team": { + "title": "团队", + "new": "新建团队" + }, "title": "智能体", + "newAgent": "新建智能体", "filter": { "all": "全部", "none": "无" @@ -35,7 +42,7 @@ "titleRunning": "已在运行 — 点击以聚焦", "titleSpawn": "在 PTY 面板中启动", "noTypes": "—", - "emptyState": "暂无智能体。使用 + 新建 → 智能体。" + "emptyState": "暂无智能体。使用上方的 + 按钮添加。" }, "user": { "title": "{{team}} 中的 app-user" @@ -60,6 +67,10 @@ "swapTitle": "点击后再点击此标签页中的另一个面板即可交换位置" }, "chat": { + "title": "聊天", + "minimize": "最小化", + "maximize": "最大化", + "restore": "还原", "peer": { "to": "→ {{to}}", "from": "{{from}} →" @@ -71,7 +82,7 @@ } }, "composer": { - "asPrefix": "以", + "asLabel": "以 {{appUser}} 身份", "targetPlaceholder": "发送至…", "messagePlaceholder": "消息", "sendButton": "发送", @@ -148,6 +159,12 @@ "layoutVertical": "纵向", "layoutHorizontal": "横向", "layoutTile": "平铺" + }, + "room": { + "hide": "隐藏团队聊天室" + }, + "chat": { + "hide": "隐藏我的聊天" } }, "terminal": { @@ -158,7 +175,14 @@ "label": "语言" }, "settings": { - "title": "设置" + "title": "设置", + "terminalFontSize": { + "label": "终端字体大小" + }, + "timezone": { + "label": "时区", + "auto": "自动 ({{zone}})" + } }, "nativeMenu": { "about": "关于 {{name}}", @@ -175,6 +199,7 @@ "paste": "粘贴", "selectAll": "全选", "viewMenu": "显示", + "showTeamRoom": "显示团队聊天室", "showUserChat": "显示我的聊天", "paneLayout": "面板布局", "paneLayoutVertical": "纵向", @@ -196,5 +221,8 @@ "updateFailed": "更新失败:{{error}}", "upToDate": "当前已是最新版本。", "noUpdatesTitle": "没有可用更新" + }, + "stage": { + "emptyHint": "点击左侧的成员以启动代理。" } } diff --git a/app/src/i18n/locales/zh-TW.json b/app/src/i18n/locales/zh-TW.json index d5d712c3..822e31c8 100644 --- a/app/src/i18n/locales/zh-TW.json +++ b/app/src/i18n/locales/zh-TW.json @@ -20,12 +20,19 @@ }, "sidebar": { "logoAlt": "agmsg", + "collapse": "收合側邊欄", + "expand": "展開側邊欄", "newMenu": { "trigger": "+ 新增 ▾", "team": "團隊…", "agent": "代理…" }, + "team": { + "title": "團隊", + "new": "新增團隊" + }, "title": "代理", + "newAgent": "新增代理", "filter": { "all": "全部", "none": "無" @@ -35,7 +42,7 @@ "titleRunning": "已在執行中 — 點選以切換至該面板", "titleSpawn": "在 PTY 面板中啟動", "noTypes": "—", - "emptyState": "尚無代理。請使用「+ 新增 → 代理」新增。" + "emptyState": "尚無代理。請使用上方的「+」按鈕新增。" }, "user": { "title": "{{team}} 的 app-user" @@ -60,6 +67,10 @@ "swapTitle": "點擊後再點擊此分頁中的另一個面板即可交換位置" }, "chat": { + "title": "聊天", + "minimize": "最小化", + "maximize": "最大化", + "restore": "還原", "peer": { "to": "→ {{to}}", "from": "{{from}} →" @@ -71,7 +82,7 @@ } }, "composer": { - "asPrefix": "以", + "asLabel": "以 {{appUser}} 身分", "targetPlaceholder": "傳送給…", "messagePlaceholder": "訊息", "sendButton": "傳送", @@ -148,6 +159,12 @@ "layoutVertical": "縱向", "layoutHorizontal": "橫向", "layoutTile": "平鋪" + }, + "room": { + "hide": "隱藏團隊聊天室" + }, + "chat": { + "hide": "隱藏我的聊天" } }, "terminal": { @@ -158,7 +175,14 @@ "label": "語言" }, "settings": { - "title": "設定" + "title": "設定", + "terminalFontSize": { + "label": "終端機字型大小" + }, + "timezone": { + "label": "時區", + "auto": "自動 ({{zone}})" + } }, "nativeMenu": { "about": "關於 {{name}}", @@ -175,6 +199,7 @@ "paste": "貼上", "selectAll": "全選", "viewMenu": "顯示", + "showTeamRoom": "顯示團隊聊天室", "showUserChat": "顯示我的聊天", "paneLayout": "窗格版面配置", "paneLayoutVertical": "縱向", @@ -196,5 +221,8 @@ "updateFailed": "更新失敗:{{error}}", "upToDate": "目前已是最新版本。", "noUpdatesTitle": "沒有可用更新" + }, + "stage": { + "emptyHint": "點擊左側的成員以啟動代理。" } } diff --git a/app/src/modals.test.ts b/app/src/modals.test.ts new file mode 100644 index 00000000..92167cbc --- /dev/null +++ b/app/src/modals.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { sanitizeNumberDraft, shouldCloseOnEscape, stepFontSize } from "./modals"; + +function esc(overrides: Partial<{ isComposing: boolean; keyCode: number; defaultPrevented: boolean }> = {}) { + return { + key: "Escape", + isComposing: false, + keyCode: 27, + defaultPrevented: false, + ...overrides, + }; +} + +describe("shouldCloseOnEscape", () => { + it("closes on a plain Escape", () => { + expect(shouldCloseOnEscape(esc())).toBe(true); + }); + + it("ignores non-Escape keys", () => { + expect(shouldCloseOnEscape({ ...esc(), key: "Enter" })).toBe(false); + }); + + it("does not close while an IME composition is in progress", () => { + expect(shouldCloseOnEscape(esc({ isComposing: true }))).toBe(false); + }); + + it("does not close on the WKWebView IME keyCode 229 fallback", () => { + expect(shouldCloseOnEscape(esc({ keyCode: 229 }))).toBe(false); + }); + + it("does not close when a child already consumed the event", () => { + expect(shouldCloseOnEscape(esc({ defaultPrevented: true }))).toBe(false); + }); +}); + +describe("sanitizeNumberDraft", () => { + it("passes plain digits through unchanged", () => { + expect(sanitizeNumberDraft("12")).toBe("12"); + }); + + it("strips letters and symbols WKWebView can let slip into a number input", () => { + expect(sanitizeNumberDraft("1a2b")).toBe("12"); + expect(sanitizeNumberDraft("1e5")).toBe("15"); + expect(sanitizeNumberDraft("!@#12$%")).toBe("12"); + }); + + it("keeps a single leading minus sign", () => { + expect(sanitizeNumberDraft("-12")).toBe("-12"); + }); + + it("drops a minus sign anywhere but the first character", () => { + expect(sanitizeNumberDraft("1-2")).toBe("12"); + expect(sanitizeNumberDraft("12-")).toBe("12"); + expect(sanitizeNumberDraft("--12")).toBe("-12"); + }); + + it("keeps only the first decimal point", () => { + expect(sanitizeNumberDraft("1.2.3")).toBe("1.23"); + expect(sanitizeNumberDraft("1..2")).toBe("1.2"); + }); + + it("allows a bare decimal point mid-edit (e.g. typing '12.' before the fraction)", () => { + expect(sanitizeNumberDraft("12.")).toBe("12."); + }); + + it("returns an empty string for entirely non-numeric input", () => { + expect(sanitizeNumberDraft("abc")).toBe(""); + }); + + it("passes an already-empty string through unchanged", () => { + expect(sanitizeNumberDraft("")).toBe(""); + }); +}); + +describe("stepFontSize", () => { + it("steps up by 1 from a valid draft", () => { + expect(stepFontSize("12", 12, 1, 8, 24)).toBe(13); + }); + + it("steps down by 1 from a valid draft", () => { + expect(stepFontSize("12", 12, -1, 8, 24)).toBe(11); + }); + + it("falls back to the committed value when the draft doesn't parse (e.g. empty, mid-edit)", () => { + expect(stepFontSize("", 12, 1, 8, 24)).toBe(13); + expect(stepFontSize("-", 12, 1, 8, 24)).toBe(13); + expect(stepFontSize(".", 12, -1, 8, 24)).toBe(11); + }); + + it("clamps at the maximum", () => { + expect(stepFontSize("24", 24, 1, 8, 24)).toBe(24); + }); + + it("clamps at the minimum", () => { + expect(stepFontSize("8", 8, -1, 8, 24)).toBe(8); + }); + + it("steps from a decimal draft and can land on a non-integer", () => { + expect(stepFontSize("12.5", 12.5, 1, 8, 24)).toBe(13.5); + }); +}); diff --git a/app/src/modals.tsx b/app/src/modals.tsx index af917ae2..367c0ff7 100644 --- a/app/src/modals.tsx +++ b/app/src/modals.tsx @@ -1,16 +1,45 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { invoke } from "@tauri-apps/api/core"; import { SUPPORTED_LANGUAGES } from "./i18n"; +import { AUTO_TIMEZONE, detectTimeZone, isValidTimeZone, listTimeZones } from "./time"; type BrowseDir = (current: string) => Promise<string | null>; +/** + * Whether an Escape keydown should close a modal. Excludes IME composition: + * while composing (e.g. converting kana to kanji), Escape cancels the + * pending conversion rather than acting on the page, and WKWebView doesn't + * always set `isComposing` for that event — keyCode 229 is the traditional + * cross-browser signal for "this keydown belongs to an IME," so it's + * checked too. Also respects `defaultPrevented`, so a child (a native + * select, a datalist) that already consumed the Escape keeps the modal + * open. Exported as a pure function so this logic is unit-testable without + * mounting a component. + */ +export function shouldCloseOnEscape(e: Pick<KeyboardEvent, "key" | "isComposing" | "keyCode" | "defaultPrevented">): boolean { + if (e.key !== "Escape") return false; + if (e.defaultPrevented) return false; + if (e.isComposing || e.keyCode === 229) return false; + return true; +} + /** Modal chrome: dimmed backdrop + centered card. */ function Modal(props: { title: string; children: React.ReactNode; onClose?: () => void; }) { + const { onClose } = props; + useEffect(() => { + if (!onClose) return; + const onKeyDown = (e: KeyboardEvent) => { + if (shouldCloseOnEscape(e)) onClose(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [onClose]); + return ( <div className="modal-backdrop" onClick={props.onClose}> <div className="modal" onClick={(e) => e.stopPropagation()}> @@ -364,11 +393,109 @@ export function ConfirmModal(props: { ); } -// The only setting today is language, so this is a single dropdown rather -// than a full preferences panel — expand into sections here as more -// settings show up instead of adding separate modals per setting. -export function SettingsModal(props: { onClose: () => void }) { +// Exported so App.tsx can validate a localStorage-restored value against the +// same range this modal's <input> enforces (see terminalFontSize's lazy +// useState initializer). +export const MIN_TERMINAL_FONT_SIZE = 8; +export const MAX_TERMINAL_FONT_SIZE = 24; + +// type="number" doesn't reliably block non-numeric characters in every +// webview engine (koit's real-hardware report: WKWebView on macOS let them +// through into the field). Strips anything that isn't a digit, a leading +// "-", or the first "." — applied to the DRAFT text itself before it's +// shown, not just before committing, so a rejected character never +// visibly lands in the field even for a frame. +export function sanitizeNumberDraft(raw: string): string { + let result = ""; + let seenDot = false; + for (let i = 0; i < raw.length; i++) { + const ch = raw[i]; + if (ch === "-" && i === 0) { + result += ch; + } else if (ch === "." && !seenDot) { + seenDot = true; + result += ch; + } else if (ch >= "0" && ch <= "9") { + result += ch; + } + } + return result; +} + +// ArrowUp/ArrowDown stepping for the font-size field, extracted as a pure +// function so its clamping/fallback logic is unit-testable independent of +// the composition-guarded DOM event handler that calls it (see the +// onKeyDown below — the IME-composition check itself isn't something this +// helper can or should own). +export function stepFontSize( + draftText: string, + fallback: number, + direction: 1 | -1, + min: number, + max: number, +): number { + // Number("") is 0, not NaN — without the trim/empty check an empty draft + // would step from 0 instead of falling back to the last committed value. + const current = draftText.trim() === "" ? NaN : Number(draftText); + const base = Number.isFinite(current) ? current : fallback; + return Math.min(max, Math.max(min, base + direction)); +} + +export function SettingsModal(props: { + onClose: () => void; + terminalFontSize: number; + onTerminalFontSizeChange: (size: number) => void; + timezone: string; + onTimezoneChange: (timezone: string) => void; +}) { const { t, i18n } = useTranslation(); + // Local draft text, not a number bound directly to props.terminalFontSize + // — a controlled input that only accepts values already in [MIN, MAX] + // rejects every keystroke of a multi-digit entry whose intermediate value + // falls outside that range (e.g. typing "20" from scratch: "2" alone is + // < MIN and would otherwise be silently dropped, visually reverting the + // field). Free typing (including decimals, an empty field mid-edit) is + // always shown; only a complete, valid, in-range value is committed. + const [fontSizeText, setFontSizeText] = useState(() => String(props.terminalFontSize)); + // A ref, not state — read/written synchronously inside the same tick as + // composition/change events, no re-render needed for it on its own. + const isComposingFontSize = useRef(false); + const commitFontSizeText = (raw: string) => { + const text = sanitizeNumberDraft(raw); + setFontSizeText(text); + const n = Number(text); + if (text.trim() !== "" && Number.isFinite(n) && n >= MIN_TERMINAL_FONT_SIZE && n <= MAX_TERMINAL_FONT_SIZE) { + props.onTerminalFontSizeChange(n); + } + }; + // Computed once per modal open, not on every keystroke — the full zone + // list (400+ IANA names) doesn't change while the dropdown is open. + const [timeZones] = useState(listTimeZones); + // A plain <select> with 400+ flat IANA names is nearly unusable: a native + // select's keyboard jump only matches the START of an option ("Asia/Tokyo" + // never matches typing "Tokyo"). A text input + <datalist> keeps this + // dependency-free while getting the browser's own substring-matching + // suggestion filtering. Local draft text so a still-typing/partial value + // can be shown without committing an invalid zone to app state. + const autoLabel = t("settings.timezone.auto", { zone: detectTimeZone() }); + const [timezoneText, setTimezoneText] = useState(() => + props.timezone === AUTO_TIMEZONE ? autoLabel : props.timezone, + ); + // Switching language mid-modal recomputes autoLabel (it's translated) but + // wouldn't otherwise touch this draft, leaving it showing the old + // language's "Auto (...)" text. Only re-sync when the draft still equals + // the PREVIOUS autoLabel and the committed timezone is still auto — never + // clobbers a custom zone name the user is typing/has typed. + const lastAutoLabelRef = useRef(autoLabel); + useEffect(() => { + if (props.timezone === AUTO_TIMEZONE && timezoneText === lastAutoLabelRef.current) { + setTimezoneText(autoLabel); + } + lastAutoLabelRef.current = autoLabel; + // Deliberately scoped to autoLabel changes only (see comment above) — + // timezoneText/props.timezone are read via closure, not deps, so this + // doesn't re-fire on every keystroke or timezone change. + }, [autoLabel]); return ( <Modal title={t("modal.settings.title")} onClose={props.onClose}> <label> @@ -384,6 +511,94 @@ export function SettingsModal(props: { onClose: () => void }) { ))} </select> </label> + <label> + {t("settings.terminalFontSize.label")} + <input + // Not type="number": per spec, a number input's .value is forced + // to "" the instant its content doesn't parse as a number (the + // "bad input" state) — but the browser can keep showing the + // invalid text it actually rendered regardless, and a React + // controlled re-render doesn't reliably win against that native + // display state. inputMode="decimal" still hints a numeric + // keyboard on touch without any of that native sanitization + // fighting our own (sanitizeNumberDraft + the range check below + // already do full validation manually). + type="text" + inputMode="decimal" + value={fontSizeText} + onChange={(e) => { + // While an IME composition is in progress (e.g. typing romaji + // that's live-converting to hiragana), the browser owns the + // field's displayed text and won't let a controlled re-render + // override it — sanitizing here would have no visible effect + // until composition ends anyway, so just mirror it as-is and + // let onCompositionEnd below do the real filtering once there's + // a final value to filter. + if (isComposingFontSize.current) { + setFontSizeText(e.target.value); + return; + } + commitFontSizeText(e.target.value); + }} + onCompositionStart={() => { + isComposingFontSize.current = true; + }} + onCompositionEnd={(e) => { + isComposingFontSize.current = false; + commitFontSizeText(e.currentTarget.value); + }} + onBlur={(e) => { + // Defensive catch-all: if focus leaves the field while a + // composition is somehow still open (or any other path + // resulted in unsanitized text reaching fontSizeText), + // losing focus is the last chance to clean it up. + isComposingFontSize.current = false; + commitFontSizeText(e.currentTarget.value); + }} + onKeyDown={(e) => { + // type="number" gave ArrowUp/ArrowDown stepping for free; + // type="text" doesn't, so re-implement it (step 1, clamped). + if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return; + // Never hijack IME candidate navigation — during composition, + // ArrowUp/ArrowDown move the IME's own conversion-candidate + // selection, not this field's value. isComposingFontSize.current + // and e.nativeEvent.isComposing cover the standard case; + // keyCode 229 is the legacy fallback some engines still report + // for a composition keydown where isComposing isn't reliably set. + if (isComposingFontSize.current || e.nativeEvent.isComposing || e.keyCode === 229) return; + e.preventDefault(); + const direction = e.key === "ArrowUp" ? 1 : -1; + commitFontSizeText( + String( + stepFontSize(fontSizeText, props.terminalFontSize, direction, MIN_TERMINAL_FONT_SIZE, MAX_TERMINAL_FONT_SIZE), + ), + ); + }} + /> + </label> + <label> + {t("settings.timezone.label")} + <input + type="text" + list="settings-timezone-options" + value={timezoneText} + onChange={(e) => { + const v = e.target.value; + setTimezoneText(v); + // Only commit a complete, valid entry — mid-typing text (e.g. + // "Tokyo" before the datalist suggestion is picked) shouldn't + // overwrite the persisted timezone with something invalid. + if (v === autoLabel) props.onTimezoneChange(AUTO_TIMEZONE); + else if (isValidTimeZone(v)) props.onTimezoneChange(v); + }} + /> + <datalist id="settings-timezone-options"> + <option value={autoLabel} /> + {timeZones.map((zone) => ( + <option key={zone} value={zone} /> + ))} + </datalist> + </label> <div className="modal-actions"> <button type="button" className="primary" onClick={props.onClose}> {t("common.close")} diff --git a/app/src/paneTree.test.ts b/app/src/paneTree.test.ts index bd26b335..a4d6813c 100644 --- a/app/src/paneTree.test.ts +++ b/app/src/paneTree.test.ts @@ -5,6 +5,7 @@ import { clampRatio, collectDividers, computeRects, + dividerDragKey, insertAsNewLeaf, insertBeside, leaves, @@ -655,3 +656,31 @@ describe("transposeGrid", () => { expect(rects.get("4")!.height).toBeCloseTo(50, 6); }); }); + +describe("dividerDragKey", () => { + it("stays the same across a grid-segment transpose (regression, co1 review PR #390)", () => { + // A 3-column aligned grid: transposing a grid-segment divider from one + // of these turns the OTHER segments' dividers from "grid-segment" into + // "single" (a 2x2 grid stays grid-shaped either way — this needed 3+ + // columns to reproduce). App.tsx keys its drag-highlight off this + // identity precisely because the divider's own DOM node can get + // unmounted by that shape change mid-drag. + const row = (leftId: string, rest: SplitNode) => split("col", 0.4, leaf(leftId), rest); + const tree = split( + "row", + 0.5, + row("1", split("col", 0.6, leaf("2"), leaf("3"))), + row("4", split("col", 0.6, leaf("5"), leaf("6"))), + ); + const before = collectDividers(tree); + const grabbed = before.find((d) => d.kind === "grid-segment")!; + expect(grabbed.kind).toBe("grid-segment"); + const keyBefore = dividerDragKey(grabbed); + + // Mirrors startPaneDividerDrag: transpose at the grabbed divider's own + // basePath, exactly as grabbing it does. + const transposed = applyAtPath(tree, (grabbed as Extract<DividerInfo, { kind: "grid-segment" }>).basePath, transposeGrid); + const after = collectDividers(transposed); + expect(after.some((d) => dividerDragKey(d) === keyBefore)).toBe(true); + }); +}); diff --git a/app/src/paneTree.ts b/app/src/paneTree.ts index 8f9b948a..1d649dfc 100644 --- a/app/src/paneTree.ts +++ b/app/src/paneTree.ts @@ -207,6 +207,21 @@ export function collectDividers(node: SplitNode, rect: PaneRect = FULL_RECT, pat return [...thisSeam, ...collectDividers(node.a, rectA, [...path, "a"]), ...collectDividers(node.b, rectB, [...path, "b"])]; } +/** + * A divider's identity across a grid-segment transpose (co1 review, PR + * #390): grabbing a grid-segment divider transposes its aligned grid at + * drag-start (see transposeGrid's own doc), and for 3+ segment grids that + * changes collectDividers' shape from "grid" divider keys to "single" + * ones — a DOM node keyed on the pre-transpose divider gets unmounted + * mid-drag. `[...basePath, ...segmentPath]` (or `path` directly for an + * already-single divider) stays the correct path to the same split node on + * both sides of that transpose, so App.tsx keys its drag-highlight off + * this instead of a captured DOM reference. + */ +export function dividerDragKey(d: DividerInfo): string { + return (d.kind === "single" ? d.path : [...d.basePath, ...d.segmentPath]).join(".") || "root"; +} + function zipPairs(a: SplitNode, b: SplitNode, pairAxis: SplitAxis, pairRatio: number): SplitNode { if (a.kind === "leaf" && b.kind === "leaf") { return { kind: "split", axis: pairAxis, ratio: pairRatio, a, b }; diff --git a/app/src/pulseSync.tsx b/app/src/pulseSync.tsx new file mode 100644 index 00000000..881761db --- /dev/null +++ b/app/src/pulseSync.tsx @@ -0,0 +1,65 @@ +import { useLayoutEffect, useRef, type CSSProperties, type ReactNode } from "react"; + +/** + * Negative animation-delay (a CSS time string, e.g. "-350ms") that phase- + * locks a CSS animation of the given period to the wall clock: at any real + * moment, every element sampling this at ITS OWN animation-start instant + * lands on the same frame, regardless of when each one actually started + * animating (koit: unsynced pulsing across panes read as noisy; getting + * them to pulse together "looks cool"). The instant of sampling matters — + * see PulseDot below for why this can't just be computed once and shared. + */ +export function pulseDelay(periodMs: number): string { + return `-${Date.now() % periodMs}ms`; +} + +type PulseDotProps = { + periodMs: number; + active?: boolean; + className?: string; + title?: string; + style?: CSSProperties; + children?: ReactNode; +}; + +/** + * A span carrying a `--pulse-delay` custom property (consumed by CSS via + * `animation-delay: var(--pulse-delay)`, either directly or through a + * ::before pseudo-element that inherits it), resampled from the wall + * clock at the exact moment this dot's animation actually starts — on + * mount, and again every time `active` flips from false to true. + * + * A single delay value computed once (e.g. at the app root) only phase- + * locks elements whose own animation happens to start at that same + * instant. An agent that goes "working" ten minutes after launch, or a + * pane opened later, starts its animation timeline at ITS mount/activate + * time — offsetting a fixed delay sampled earlier just shifts that + * element's phase by however long it waited, which is indistinguishable + * from not being synced at all. Resampling per-element, at its own start, + * is what actually keeps every element of a period in phase with the wall + * clock (and therefore with each other). + * + * Every agent-status / team-status / monitor pulsing dot should render + * through this one component rather than reimplementing the sampling. + */ +export function PulseDot({ + periodMs, + active = true, + className, + title, + style, + children, +}: PulseDotProps) { + const ref = useRef<HTMLSpanElement>(null); + + useLayoutEffect(() => { + if (!active) return; + ref.current?.style.setProperty("--pulse-delay", pulseDelay(periodMs)); + }, [active, periodMs]); + + return ( + <span ref={ref} className={className} title={title} style={style}> + {children} + </span> + ); +} diff --git a/app/src/time.test.ts b/app/src/time.test.ts new file mode 100644 index 00000000..9cecd57f --- /dev/null +++ b/app/src/time.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { AUTO_TIMEZONE, formatMessageTime, isValidTimeZone, resolveTimeZone } from "./time"; + +describe("formatMessageTime", () => { + it("converts a UTC timestamp to the given IANA zone", () => { + // 2026-07-14T20:23:55Z -> 05:23:55 the next day in Tokyo (UTC+9) + expect(formatMessageTime("2026-07-14T20:23:55Z", "Asia/Tokyo")).toBe("05:23:55"); + }); + + it("is a no-op conversion for UTC itself", () => { + expect(formatMessageTime("2026-07-14T20:23:55Z", "UTC")).toBe("20:23:55"); + }); + + it("handles a negative-offset zone crossing midnight backwards", () => { + // 2026-07-14T02:00:00Z -> 2026-07-13 22:00:00 in New York (UTC-4, EDT in July) + expect(formatMessageTime("2026-07-14T02:00:00Z", "America/New_York")).toBe("22:00:00"); + }); + + it("falls back to the raw slice on an unparseable timestamp", () => { + expect(formatMessageTime("not-a-date", "UTC")).toBe("not-a-date".slice(11, 19)); + }); + + it("falls back to the raw slice on an invalid timezone", () => { + const createdAt = "2026-07-14T20:23:55Z"; + expect(formatMessageTime(createdAt, "Not/AZone")).toBe(createdAt.slice(11, 19)); + }); + + it("renders midnight as 00:00:00, not 24:00:00", () => { + // hour12: false is documented to yield the 24-hour "0-23" cycle rather + // than the "1-24" cycle some locales default to for a bare hour: "2-digit". + expect(formatMessageTime("2026-07-14T00:00:00Z", "UTC")).toBe("00:00:00"); + }); +}); + +describe("isValidTimeZone", () => { + it("accepts a real IANA zone", () => { + expect(isValidTimeZone("Asia/Tokyo")).toBe(true); + }); + + it("rejects a made-up zone name", () => { + expect(isValidTimeZone("Not/AZone")).toBe(false); + }); +}); + +describe("resolveTimeZone", () => { + it("passes a valid explicit zone through unchanged", () => { + expect(resolveTimeZone("Europe/Paris")).toBe("Europe/Paris"); + }); + + it("resolves the auto sentinel to a real zone, not the sentinel itself", () => { + expect(resolveTimeZone(AUTO_TIMEZONE)).not.toBe(AUTO_TIMEZONE); + expect(typeof resolveTimeZone(AUTO_TIMEZONE)).toBe("string"); + }); + + it("falls back to the auto-detected zone for an invalid stored value", () => { + expect(resolveTimeZone("Not/AZone")).toBe(resolveTimeZone(AUTO_TIMEZONE)); + }); +}); diff --git a/app/src/time.ts b/app/src/time.ts new file mode 100644 index 00000000..ac3731d4 --- /dev/null +++ b/app/src/time.ts @@ -0,0 +1,94 @@ +// Timezone-aware formatting for chat/team-room timestamps. agmsg's message +// store writes created_at as UTC (strftime('%Y-%m-%dT%H:%M:%SZ', 'now') — +// see init-db.sh), so displaying it correctly always requires a conversion; +// there is no "just show the string" option (see issue #393). + +/** Sentinel stored/selected value meaning "follow the OS timezone live", + * rather than freezing whatever was detected at first launch. */ +export const AUTO_TIMEZONE = "auto"; + +export function detectTimeZone(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone; + } catch { + return "UTC"; + } +} + +/** All IANA zone names the runtime knows about, for the Settings dropdown. + * `Intl.supportedValuesOf` landed in evergreen webviews in 2022 — Tauri v2's + * minimum WebView2/WebKit versions are new enough, but this degrades to + * just the detected zone (still usable, just not a full picker) rather than + * throwing on an unexpectedly old webview. */ +export function listTimeZones(): string[] { + const intl = Intl as unknown as { supportedValuesOf?: (key: string) => string[] }; + try { + const zones = intl.supportedValuesOf?.("timeZone"); + if (zones && zones.length > 0) return zones; + } catch { + // fall through to the single-zone fallback below + } + return [detectTimeZone()]; +} + +export function isValidTimeZone(timeZone: string): boolean { + try { + new Intl.DateTimeFormat(undefined, { timeZone }); + return true; + } catch { + return false; + } +} + +// One formatter per zone, reused across every message/render — a chat +// history with hundreds of visible messages would otherwise construct a +// fresh Intl.DateTimeFormat per message per render for what's always the +// same (timeZone, options) pair. +const formatterCache = new Map<string, Intl.DateTimeFormat>(); +function formatterFor(timeZone: string): Intl.DateTimeFormat { + let formatter = formatterCache.get(timeZone); + if (!formatter) { + formatter = new Intl.DateTimeFormat("en-US", { + timeZone, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + // hour12: false alone is ambiguous — depending on the ICU version, + // "false" can still resolve to the h24 cycle (which renders midnight + // as "24", not "00"). hourCycle: "h23" is the unambiguous way to pin + // 0-23 with midnight as "00" (caught by CI running a different Node + // than local dev — see the midnight regression test). + hourCycle: "h23", + }); + formatterCache.set(timeZone, formatter); + } + return formatter; +} + +/** Formats a UTC ISO 8601 timestamp as 24-hour HH:MM:SS in `timeZone`. + * Built from Intl.DateTimeFormat's parts (not its formatted string) to + * avoid locale-specific punctuation/midnight-as-"24:00" quirks across + * webview engines — this only ever needs three zero-padded numbers. */ +export function formatMessageTime(createdAt: string, timeZone: string): string { + const d = new Date(createdAt); + if (Number.isNaN(d.getTime())) return createdAt.slice(11, 19); + try { + const parts = formatterFor(timeZone).formatToParts(d); + const get = (type: string) => parts.find((p) => p.type === type)?.value ?? "00"; + return `${get("hour")}:${get("minute")}:${get("second")}`; + } catch { + return createdAt.slice(11, 19); + } +} + +/** Resolves the "auto" sentinel to a real IANA zone; passes a valid + * explicit override through unchanged. Falls back to the auto-detected + * zone for anything invalid (a corrupted localStorage value, or a zone + * name the runtime no longer recognizes) rather than letting an unknown + * zone reach Intl.DateTimeFormat, which would make formatMessageTime fall + * back to the raw UTC slice — silently reintroducing #393 for that one + * stored value. */ +export function resolveTimeZone(selected: string): string { + if (selected === AUTO_TIMEZONE) return detectTimeZone(); + return isValidTimeZone(selected) ? selected : detectTimeZone(); +} diff --git a/app/src/writeBatcher.test.ts b/app/src/writeBatcher.test.ts new file mode 100644 index 00000000..0b757275 --- /dev/null +++ b/app/src/writeBatcher.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it, vi } from "vitest"; +import { createWriteBatcher } from "./writeBatcher"; + +const bytes = (s: string) => new TextEncoder().encode(s); +const text = (b: Uint8Array) => new TextDecoder().decode(b); + +// Fully manual/injected scheduling — no real timers or requestAnimationFrame +// — so every test controls exactly which of (frame, timer, byte threshold) +// fires first, deterministically. +function fakeSchedulers() { + let nextHandle = 1; + const frameCallbacks = new Map<number, () => void>(); + const timerCallbacks = new Map<number, () => void>(); + return { + requestFrame: vi.fn((cb: () => void) => { + const h = nextHandle++; + frameCallbacks.set(h, cb); + return h; + }), + cancelFrame: vi.fn((h: number) => frameCallbacks.delete(h)), + setTimer: vi.fn((cb: () => void, _ms: number) => { + const h = nextHandle++; + timerCallbacks.set(h, cb); + return h as unknown as ReturnType<typeof setTimeout>; + }), + clearTimer: vi.fn((h: ReturnType<typeof setTimeout>) => timerCallbacks.delete(h as unknown as number)), + fireFrame: () => { + const [h, cb] = [...frameCallbacks.entries()][0]; + frameCallbacks.delete(h); + cb(); + }, + fireTimer: () => { + const [h, cb] = [...timerCallbacks.entries()][0]; + timerCallbacks.delete(h); + cb(); + }, + }; +} + +describe("createWriteBatcher", () => { + it("does not flush until the frame callback fires", () => { + const s = fakeSchedulers(); + const onFlush = vi.fn(); + const batcher = createWriteBatcher({ onFlush, ...s }); + batcher.push(bytes("hi")); + expect(onFlush).not.toHaveBeenCalled(); + s.fireFrame(); + expect(onFlush).toHaveBeenCalledTimes(1); + expect(text(onFlush.mock.calls[0][0])).toBe("hi"); + }); + + it("flushes via the latency timer if the frame never fires (backgrounded webview)", () => { + const s = fakeSchedulers(); + const onFlush = vi.fn(); + const batcher = createWriteBatcher({ onFlush, ...s }); + batcher.push(bytes("bg")); + // Simulate a suspended requestAnimationFrame: only the timer fires. + s.fireTimer(); + expect(onFlush).toHaveBeenCalledTimes(1); + expect(text(onFlush.mock.calls[0][0])).toBe("bg"); + }); + + it("cancels the timer once the frame fires first", () => { + const s = fakeSchedulers(); + const batcher = createWriteBatcher({ onFlush: vi.fn(), ...s }); + batcher.push(bytes("x")); + s.fireFrame(); + expect(s.clearTimer).toHaveBeenCalledTimes(1); + }); + + it("cancels the frame once the timer fires first", () => { + const s = fakeSchedulers(); + const batcher = createWriteBatcher({ onFlush: vi.fn(), ...s }); + batcher.push(bytes("x")); + s.fireTimer(); + expect(s.cancelFrame).toHaveBeenCalledTimes(1); + }); + + it("flushes immediately once pending bytes reach the threshold, without waiting for scheduling", () => { + const s = fakeSchedulers(); + const onFlush = vi.fn(); + const batcher = createWriteBatcher({ onFlush, maxPendingBytes: 5, ...s }); + batcher.push(bytes("abc")); // 3 bytes, schedules + expect(onFlush).not.toHaveBeenCalled(); + batcher.push(bytes("def")); // total 6 >= 5 -> flush now, cancels the schedule + expect(onFlush).toHaveBeenCalledTimes(1); + expect(text(onFlush.mock.calls[0][0])).toBe("abcdef"); + expect(s.cancelFrame).toHaveBeenCalledTimes(1); + expect(s.clearTimer).toHaveBeenCalledTimes(1); + }); + + it("merges multiple pushed chunks in arrival order", () => { + const s = fakeSchedulers(); + const onFlush = vi.fn(); + const batcher = createWriteBatcher({ onFlush, ...s }); + batcher.push(bytes("one-")); + batcher.push(bytes("two-")); + batcher.push(bytes("three")); + s.fireFrame(); + expect(text(onFlush.mock.calls[0][0])).toBe("one-two-three"); + }); + + it("only schedules once across multiple pushes before a flush", () => { + const s = fakeSchedulers(); + const batcher = createWriteBatcher({ onFlush: vi.fn(), ...s }); + batcher.push(bytes("a")); + batcher.push(bytes("b")); + batcher.push(bytes("c")); + expect(s.requestFrame).toHaveBeenCalledTimes(1); + expect(s.setTimer).toHaveBeenCalledTimes(1); + }); + + it("dispose cancels any pending schedule and drops buffered data without flushing", () => { + const s = fakeSchedulers(); + const onFlush = vi.fn(); + const batcher = createWriteBatcher({ onFlush, ...s }); + batcher.push(bytes("never written")); + batcher.dispose(); + expect(s.cancelFrame).toHaveBeenCalledTimes(1); + expect(s.clearTimer).toHaveBeenCalledTimes(1); + expect(onFlush).not.toHaveBeenCalled(); + }); + + it("dispose is permanent — a push() arriving after dispose (e.g. a Tauri listener that resolved late, after its owning component already tore down) must not resurrect scheduling or flush", () => { + const s = fakeSchedulers(); + const onFlush = vi.fn(); + const batcher = createWriteBatcher({ onFlush, ...s }); + batcher.dispose(); + s.requestFrame.mockClear(); + s.setTimer.mockClear(); + batcher.push(bytes("late arrival")); + expect(s.requestFrame).not.toHaveBeenCalled(); + expect(s.setTimer).not.toHaveBeenCalled(); + expect(onFlush).not.toHaveBeenCalled(); + // Even an explicit flushNow() call must stay inert post-dispose. + batcher.flushNow(); + expect(onFlush).not.toHaveBeenCalled(); + }); + + it("flushNow flushes and cancels scheduling on demand (e.g. before writing an exit banner)", () => { + const s = fakeSchedulers(); + const onFlush = vi.fn(); + const batcher = createWriteBatcher({ onFlush, ...s }); + batcher.push(bytes("last output")); + batcher.flushNow(); + expect(onFlush).toHaveBeenCalledTimes(1); + expect(s.cancelFrame).toHaveBeenCalledTimes(1); + expect(s.clearTimer).toHaveBeenCalledTimes(1); + }); + + it("flushNow is a no-op when nothing is pending", () => { + const s = fakeSchedulers(); + const onFlush = vi.fn(); + const batcher = createWriteBatcher({ onFlush, ...s }); + batcher.flushNow(); + expect(onFlush).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/writeBatcher.ts b/app/src/writeBatcher.ts new file mode 100644 index 00000000..cbad34b3 --- /dev/null +++ b/app/src/writeBatcher.ts @@ -0,0 +1,118 @@ +// Coalesces frequently-arriving byte chunks into a single flush, bounded +// two ways so it can never stall or grow unbounded (see issue #383's PR for +// the motivating case — batching term.write() calls per animation frame): +// +// - requestAnimationFrame alone is not enough: it throttles to ~1fps or +// stops entirely in a backgrounded/occluded webview, and agmsg mounts +// panes while hidden (display:none) and can run agents in the background +// for long stretches — output would accumulate unbounded until the pane +// became visible again (memory spike, then one giant write). +// - maxLatencyMs is a timer fallback that guarantees a flush within that +// window regardless of whether the animation frame ever fires. Whichever +// of the two fires first flushes and cancels the other. +// - maxPendingBytes is a hard cap: crossing it flushes immediately, +// synchronously, inside push() — bounds worst-case memory even if the +// frame/timer scheduling itself is somehow delayed further. + +export type WriteBatcherOptions = { + onFlush: (data: Uint8Array) => void; + maxPendingBytes?: number; + maxLatencyMs?: number; + // Injectable for testing; default to the real browser globals. + requestFrame?: (cb: () => void) => number; + cancelFrame?: (handle: number) => void; + setTimer?: (cb: () => void, ms: number) => ReturnType<typeof setTimeout>; + clearTimer?: (handle: ReturnType<typeof setTimeout>) => void; +}; + +export type WriteBatcher = { + push: (chunk: Uint8Array) => void; + /** Flush whatever is pending right now, bypassing any scheduled wait. */ + flushNow: () => void; + /** Cancel any pending schedule and drop buffered data without flushing — + * for teardown, where writing to an about-to-be-disposed target is wrong. */ + dispose: () => void; +}; + +const DEFAULT_MAX_PENDING_BYTES = 256 * 1024; +const DEFAULT_MAX_LATENCY_MS = 100; + +export function createWriteBatcher(options: WriteBatcherOptions): WriteBatcher { + const maxPendingBytes = options.maxPendingBytes ?? DEFAULT_MAX_PENDING_BYTES; + const maxLatencyMs = options.maxLatencyMs ?? DEFAULT_MAX_LATENCY_MS; + const requestFrame = options.requestFrame ?? requestAnimationFrame; + const cancelFrame = options.cancelFrame ?? cancelAnimationFrame; + const setTimer = options.setTimer ?? setTimeout; + const clearTimer = options.clearTimer ?? clearTimeout; + + let pending: Uint8Array[] = []; + let pendingBytes = 0; + let frameHandle: number | null = null; + let timerHandle: ReturnType<typeof setTimeout> | null = null; + // Permanent, not a "reset": once disposed, push/flushNow must stay no-ops + // forever. Without this, a push() arriving after teardown (a real race — + // see TerminalPane.tsx, where Tauri listener registration is async and + // can resolve after the owning effect's cleanup already ran) would + // resurrect scheduling and eventually call onFlush again, writing into + // whatever the caller tore down (e.g. a disposed xterm instance). + let disposed = false; + + function cancelScheduled() { + if (frameHandle !== null) { + cancelFrame(frameHandle); + frameHandle = null; + } + if (timerHandle !== null) { + clearTimer(timerHandle); + timerHandle = null; + } + } + + function flushNow() { + if (disposed) return; + cancelScheduled(); + if (pending.length === 0) return; + const merged = new Uint8Array(pendingBytes); + let offset = 0; + for (const chunk of pending) { + merged.set(chunk, offset); + offset += chunk.length; + } + pending = []; + pendingBytes = 0; + options.onFlush(merged); + } + + function schedule() { + if (disposed) return; + if (frameHandle !== null || timerHandle !== null) return; + frameHandle = requestFrame(() => { + frameHandle = null; + flushNow(); + }); + timerHandle = setTimer(() => { + timerHandle = null; + flushNow(); + }, maxLatencyMs); + } + + function push(chunk: Uint8Array) { + if (disposed) return; + pending.push(chunk); + pendingBytes += chunk.length; + if (pendingBytes >= maxPendingBytes) { + flushNow(); + return; + } + schedule(); + } + + function dispose() { + disposed = true; + cancelScheduled(); + pending = []; + pendingBytes = 0; + } + + return { push, flushNow, dispose }; +} diff --git a/bin/agmsg.js b/bin/agmsg.js index ae7eff99..8606f9b2 100755 --- a/bin/agmsg.js +++ b/bin/agmsg.js @@ -79,6 +79,19 @@ function printHelp() { ].join('\n')); } +// Normalise a native path to the forward-slash form that bash.exe and +// curl.exe accept on Windows. bash is an MSYS2 program: Windows gives it a +// raw command-line string rather than a real argv[], and MSYS's own argv +// reconstruction treats backslash as an escape character, so a native +// `C:\Users\...\setup.sh` argument gets corrupted (e.g. `\U`, `\T` are read +// as escapes) into a path that doesn't exist — "No such file or directory" +// for a file that's actually there. Forward slashes have no such ambiguity, +// and both bash and curl accept them on Windows. No-op on POSIX (no +// backslashes to replace). See #262. +function toBashPath(p) { + return p.replace(/\\/g, '/'); +} + function runInstaller(passthroughArgs) { // Fetch the canonical setup.sh to a private tempdir, then exec it directly // with bash. This keeps the installer's stdin wired to the parent process's @@ -92,7 +105,10 @@ function runInstaller(passthroughArgs) { const ref = installRef(); const setupUrl = RAW_BASE + '/' + ref + '/setup.sh'; const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agmsg-bootstrap-')); - const setupPath = path.join(tmpDir, 'setup.sh'); + // os.tmpdir()/path.join() return backslash-separated paths on Windows. + // fs.rmSync (below) handles that form fine since it's a native Node call; + // curl and bash are external processes, so they get the bash-safe form. + const setupPath = toBashPath(path.join(tmpDir, 'setup.sh')); try { const fetch = spawnSync('curl', ['-fsSL', '-o', setupPath, setupUrl], { stdio: 'inherit' }); @@ -120,22 +136,30 @@ function runInstaller(passthroughArgs) { } } -const args = process.argv.slice(2); +function main() { + const args = process.argv.slice(2); + + if (args.length === 0 || args[0] === 'install') { + // Forward anything after `install` (e.g. `agmsg install --cmd m`) to + // setup.sh, which passes "$@" through to install.sh. + const passthrough = args[0] === 'install' ? args.slice(1) : args; + runInstaller(passthrough); + } else if (args[0] === '--help' || args[0] === '-h' || args[0] === 'help') { + printHelp(); + process.exit(0); + } else if (args[0] === '--version' || args[0] === '-v') { + process.stdout.write('agmsg bootstrapper ' + readVersion() + '\n'); + process.stdout.write('canonical project: ' + REPO_URL + '\n'); + process.exit(0); + } else { + console.error('agmsg: unknown argument: ' + args[0]); + console.error('Run `npx agmsg --help` for usage.'); + process.exit(2); + } +} -if (args.length === 0 || args[0] === 'install') { - // Forward anything after `install` (e.g. `agmsg install --cmd m`) to - // setup.sh, which passes "$@" through to install.sh. - const passthrough = args[0] === 'install' ? args.slice(1) : args; - runInstaller(passthrough); -} else if (args[0] === '--help' || args[0] === '-h' || args[0] === 'help') { - printHelp(); - process.exit(0); -} else if (args[0] === '--version' || args[0] === '-v') { - process.stdout.write('agmsg bootstrapper ' + readVersion() + '\n'); - process.stdout.write('canonical project: ' + REPO_URL + '\n'); - process.exit(0); -} else { - console.error('agmsg: unknown argument: ' + args[0]); - console.error('Run `npx agmsg --help` for usage.'); - process.exit(2); +if (require.main === module) { + main(); } + +module.exports = { toBashPath }; diff --git a/cliff.toml b/cliff.toml index 6fc0cac3..78ff67c1 100644 --- a/cliff.toml +++ b/cliff.toml @@ -69,6 +69,8 @@ commit_parsers = [ { message = "(?i)native windows", group = "<!-- 0 -->Added" }, { message = "(?i)powershell launcher", group = "<!-- 0 -->Added" }, { message = "(?i)sandboxed bash tool", group = "<!-- 1 -->Fixed" }, + { message = "^Role-to-session affinity", group = "<!-- 0 -->Added" }, + { message = "^Stop ancestor project resolution", group = "<!-- 1 -->Fixed" }, # Everything else (merges, initial commit, misc) is intentionally dropped. { message = ".*", skip = true }, ] diff --git a/docs/session-resurrect.md b/docs/session-resurrect.md new file mode 100644 index 00000000..2af7f127 --- /dev/null +++ b/docs/session-resurrect.md @@ -0,0 +1,120 @@ +# Session resume & tmux-resurrect + +This is the long-form reference for bringing a role back into its previous +conversation, introduced briefly in the [README](../README.md#bring-a-role-back-with-its-context-session-resume). +Most users only need the README summary; reach for this page when wiring up +tmux-resurrect, or when a pane comes back empty and you want to know why. + +A role `(team, agent)` has a durable link to the CLI session that last embodied +it. The moment a session `actas`'s a role, agmsg records `(team, agent) → session` +(an advisory file under the skill's `run/` directory). Two things consume that +record: + +- **`spawn`** resumes a role's recorded session by default (see + [actas.md](actas.md) for the multi-role model `spawn` builds on). +- the **tmux-resurrect hook** re-seats each role pane into its session after a + tmux-server restart. + +Resume is supported for **Claude Code** (`--resume <uuid>`) and **Codex** +(`codex resume <id>`); other agent types have no resume equivalent and always +boot fresh. + +## Setup + +Install [tmux-resurrect](https://github.com/tmux-plugins/tmux-resurrect) (and, +optionally, [tmux-continuum](https://github.com/tmux-plugins/tmux-continuum) for +automatic periodic saves). Then add one line to `~/.tmux.conf`: + +```tmux +set -g @resurrect-hook-post-restore-all '~/.agents/skills/agmsg/scripts/internal/resurrect-panes.sh' +``` + +Reload tmux (`tmux source-file ~/.tmux.conf`) and the hook runs on every restore. + +> **Do not add the agent CLI to `@resurrect-processes`.** Re-running the saved +> command verbatim is wrong in both directions: a fresh-boot command line would +> start *another* brand-new session, and a name-based resume command line stalls +> on the interactive `/resume` picker. The hook exists precisely to avoid both. + +Sessions you start by hand (`claude`, then `/agmsg actas <name>`) rather than via +`spawn` have no convention name, so tmux-resurrect can't recognize the pane. +Rename them so the pane title and `/resume` picker stay meaningful — `spawn`-born +sessions are already named this way: + +``` +/rename myteam-alice +``` + +## How it works + +- **At `actas` time**, agmsg records `(team, agent) → session id` (plus the + display name `myteam-alice`, the agent type, and the project). +- **At restore time**, tmux-resurrect brings the window/pane layout back with + each pane as a plain shell (because the CLI is deliberately *not* in + `@resurrect-processes`). The hook then reads the resurrect save file and, for + each pane whose saved title or `-n <name>` argv marker matches a recorded role, + resolves that role's current session and relaunches it into the pane with + `tmux send-keys` — the same resume-or-fresh command `spawn` builds. + +The hook never guesses: it matches the whole `<team>-<agent>` name (never split on +`-`), and it acts only on panes that restored as a shell. + +## Restoring + +**Full restore** — the whole tmux server went down (reboot, crash): + +```sh +tmux kill-server # or a reboot +# start tmux again, then: +# prefix + Ctrl-r (tmux-resurrect restore) +``` + +**Partial restore** — you only want to revive specific sessions, leaving the +rest running: + +```sh +tmux kill-session -t myteam-alice +# prefix + Ctrl-r +``` + +Partial restore is safe: the hook **skips any pane whose role is still held by a +live session**, and it never touches a pane that came back running something, so +your surviving sessions are left alone. + +## What comes back automatically, and what doesn't + +| Pane | On restore | +| --- | --- | +| A `claude-code` role with a recorded session | **Auto-seated** — resumed into its session, context restored, role re-armed | +| A role whose lock is held by a **live** session elsewhere | **Skipped** — it's already seated; reseating would double-launch | +| A pane with no role marker (a hand-started session never renamed; a codex pane) | Left as a **plain shell** — `spawn` or `actas` it again | +| A role whose recorded transcript is gone | Boots **fresh** (the stale record is ignored) | + +## Manual fallback + +If a pane didn't auto-seat and you want it back by hand, resume in the pane and +re-run `actas`: + +```sh +claude --resume <uuid> # or: claude --resume myteam-alice (picker) +# then, in the resumed session: +/agmsg actas alice +``` + +The recorded session id lives in the skill's `run/role-session.*` files — read it +if you need the exact uuid (these are advisory state; only read them, never edit). + +## Notes + +- **Resume restores context only.** A resumed session has no running watcher and + an unverified exclusivity lock, so re-running `actas` on resume is what + re-establishes the role-filtered watcher, re-claims the lock, and sets the + active FROM. `spawn` and the hook pass the `actas` prompt automatically; a + manual `--resume` needs the `actas` step yourself (a role-aware SessionStart + directive prompts for it). +- **`--fresh`** on `spawn` forces a brand-new session even when the role is + resumable. +- **Untrusted directory.** Resuming into a directory the CLI hasn't seen before + shows its "trust this folder?" prompt before it processes anything. Spawned and + resurrected panes run in already-trusted project directories, so this only + appears for a brand-new hand-started location. diff --git a/install.sh b/install.sh index f9ce147d..fae17f82 100755 --- a/install.sh +++ b/install.sh @@ -69,6 +69,16 @@ configure_codex_sandbox() { fi local writable_paths=("$SKILL_DIR/db" "$SKILL_DIR/teams" "$SKILL_DIR/run") + # On Windows (MSYS2/Git Bash), $SKILL_DIR is in MSYS form (/c/Users/...). + # Codex is a native Windows binary whose Rust path resolution cannot parse + # MSYS paths — /c/Users/... is resolved to C:\c\Users\... (a phantom path). + # Convert to the mixed C:/Users/... form that both the shell and Codex accept. + if command -v cygpath >/dev/null 2>&1; then + local i + for i in "${!writable_paths[@]}"; do + writable_paths[$i]="$(cygpath -m "${writable_paths[$i]}" 2>/dev/null || printf '%s' "${writable_paths[$i]}")" + done + fi local missing=() local p for p in "${writable_paths[@]}"; do diff --git a/package.json b/package.json index c8f8b2e2..a3e2dfba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agmsg", - "version": "1.1.6", + "version": "1.1.8", "description": "Cross-agent messaging via SQLite for CLI AI agents (Claude Code, Codex, Gemini CLI, GitHub Copilot CLI, Antigravity, OpenCode). The npm package is a thin bootstrapper that fetches and runs the canonical bash installer at https://github.com/fujibee/agmsg.", "bin": { "agmsg": "bin/agmsg.js" diff --git a/scripts/actas-claim.sh b/scripts/actas-claim.sh index 2174506c..34bf639e 100755 --- a/scripts/actas-claim.sh +++ b/scripts/actas-claim.sh @@ -34,6 +34,8 @@ SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" # actas-lock.sh requires SKILL_DIR source "$SCRIPT_DIR/lib/actas-lock.sh" # shellcheck disable=SC1091 source "$SCRIPT_DIR/lib/resolve-project.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/role-session.sh" # role->session record (#339) # Resolve the session's real project root (see #92) before any lookup, so an # actas issued from a subdir/worktree claims against the registered project @@ -80,6 +82,17 @@ while IFS= read -r team; do claimed="${claimed:+$claimed$'\n'}$team" done <<< "$TEAMS" +# All teams claimed. Record (team, agent) -> bare session id for each, so this +# role is resumable back into its context (#339). Keyed on the BARE sid (stable +# across resume generations), not the composite lock token. Best-effort: a +# failed record write must never fail the claim, and the record is written only +# on full success — the held/rollback path above writes none. +BARE_SID="$(agmsg_instance_bare_sid "$SESSION_ID")" +while IFS= read -r team; do + [ -z "$team" ] && continue + agmsg_role_session_record "$team" "$NAME" "$BARE_SID" "$PROJECT" "$TYPE" || true +done <<< "$TEAMS" + # Print a line describing each claimed team. One team per most projects but # the underlying model allows multi-team same-name registrations. printf 'status=ok' diff --git a/scripts/drivers/types/antigravity/template.md b/scripts/drivers/types/antigravity/template.md index 2182df78..ec82af66 100644 --- a/scripts/drivers/types/antigravity/template.md +++ b/scripts/drivers/types/antigravity/template.md @@ -58,7 +58,7 @@ Four possible outputs: - **Wait for the user's answer before proceeding.** Empty input means `1` (turn). - Map the chosen number to a mode (`1`→`turn`, `2`→`off`) and run: `~/.agents/skills/__SKILL_NAME__/scripts/delivery.sh set <mode> antigravity "$(pwd)"` - - Codex has no Monitor tool, so `monitor` and `both` modes are not offered here. + - Antigravity has no Monitor tool, so `monitor` and `both` modes are not offered here. 6. Then check inbox for the newly joined team. @@ -107,7 +107,7 @@ If argument starts with "actas" followed by an agent name (e.g. "actas alice"): 2. Run `~/.agents/skills/__SKILL_NAME__/scripts/identities.sh "$(pwd)" antigravity` to see whether the role is already registered for this (project, type). 3. If the name does not appear in the output, join under the existing team. For a single team, run `~/.agents/skills/__SKILL_NAME__/scripts/join.sh <team> <name> antigravity "$(pwd)"`. For multiple teams, ask the user which team to join the new role into. 4. Set the session's active FROM to `<name>` for every `send.sh` call until another `actas`. -5. Tell the user: "Now acting as `<name>`. Sends will use `<name>` as the from agent. (Codex has no Monitor tool, so receive still covers all of your registered roles in this project.)" +5. Tell the user: "Now acting as `<name>`. Sends will use `<name>` as the from agent. (Antigravity has no Monitor tool, so receive still covers all of your registered roles in this project.)" If argument starts with "drop" followed by an agent name (e.g. "drop alice"): 1. Parse the role name. @@ -120,7 +120,7 @@ If argument is "mode" (no further args): 2. Show the output to the user. If argument starts with "mode" followed by a mode name (e.g. "mode turn"): -1. Parse the mode. Codex supports only `turn` and `off` — reject `monitor` and `both` with: "Codex has no Monitor tool; only `turn` or `off` modes are supported." +1. Parse the mode. Antigravity supports only `turn` and `off` — reject `monitor` and `both` with: "Antigravity has no Monitor tool; only `turn` or `off` modes are supported." 2. Run: `~/.agents/skills/__SKILL_NAME__/scripts/delivery.sh set <mode> antigravity "$(pwd)"` If argument is "hook on" (legacy alias): diff --git a/scripts/drivers/types/antigravity/type.conf b/scripts/drivers/types/antigravity/type.conf index 2805ac18..fb9d63ae 100644 --- a/scripts/drivers/types/antigravity/type.conf +++ b/scripts/drivers/types/antigravity/type.conf @@ -10,4 +10,4 @@ prompt_arg=--prompt-interactive detect=explicit hooks_file=.agent/rules/agmsg.md monitor=no -delivery_modes=monitor turn both off +delivery_modes=turn off diff --git a/scripts/drivers/types/claude-code/_transcript-exists.sh b/scripts/drivers/types/claude-code/_transcript-exists.sh new file mode 100644 index 00000000..8bc13787 --- /dev/null +++ b/scripts/drivers/types/claude-code/_transcript-exists.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# claude-code driver hook: does a resumable transcript exist for <uuid>? +# +# Claude Code persists each session as +# ~/.claude/projects/<munged-project>/<uuid>.jsonl +# where <munged-project> is the ABSOLUTE project path with every character +# outside [A-Za-z0-9-] replaced by '-' (so '/', '.', and '_' all become '-'; +# existing '-' and case are preserved; runs of specials are NOT collapsed). +# Verified empirically against Claude Code 2.1.x, e.g. +# /Users/fujibee/.dotfiles -> -Users-fujibee--dotfiles +# /tmp/munge_Test.dir_ab -> -tmp-munge-Test-dir-ab +# +# This munging is the CLI's INTERNAL on-disk layout, so the knowledge lives in +# the claude-code driver and never leaks into core (spawn.sh only asks "does a +# transcript exist?"). Every failure path (unset HOME, unreadable dir, empty +# args) returns non-zero = "not found", so the resume-or-fresh boot wrapper +# fails open to a fresh session rather than resuming a phantom id. +# +# Sourced by spawn.sh when the type declares resume_arg; defines: +# agmsg_transcript_exists <uuid> <project> -> 0 if the transcript exists, else 1 + +agmsg_transcript_exists() { + local uuid="$1" project="$2" munged file + [ -n "$uuid" ] && [ -n "$project" ] || return 1 + [ -n "${HOME:-}" ] || return 1 + munged="$(printf '%s' "$project" | LC_ALL=C sed 's/[^A-Za-z0-9-]/-/g')" || return 1 + file="$HOME/.claude/projects/$munged/$uuid.jsonl" + [ -f "$file" ] +} diff --git a/scripts/drivers/types/claude-code/template.md b/scripts/drivers/types/claude-code/template.md index de9081eb..e0c41547 100644 --- a/scripts/drivers/types/claude-code/template.md +++ b/scripts/drivers/types/claude-code/template.md @@ -148,6 +148,7 @@ If argument starts with "actas" followed by an agent name (e.g. "actas alice"): The 4th argument to `watch.sh` restricts the subscription to messages addressed to `<name>` only — other roles' inbound messages stop reaching this session until another `actas` or session end. 6. Set the session's active FROM to `<name>` — use `<name>` in every `send.sh` call for the rest of this session. 7. Tell the user: "Now acting as `<name>`. Sends use `<name>` as from; receive restricted to `<name>` only." +8. **Only if this session was NOT launched via `spawn`** — check the environment variable `AGMSG_SPAWNED` (e.g. `printenv AGMSG_SPAWNED`): `spawn` exports `AGMSG_SPAWNED=1` and already named the session `<team>-<agent>` via `-n`, so when it is set, **skip this tip entirely**. When it is UNSET (a human typed `claude` then actas'd, so the session has no convention name), additionally suggest to the user: "Tip: rename this session to `<team>-<name>` with `/rename <team>-<name>` so it's easy to find in the `/resume` picker and stays labeled after a restart." `/rename` is a user-typed slash command — you cannot invoke it yourself, so only suggest it. If argument starts with "drop" followed by an agent name (e.g. "drop alice"): 1. Parse the role name. diff --git a/scripts/drivers/types/claude-code/type.conf b/scripts/drivers/types/claude-code/type.conf index 834ee129..52aff2c5 100644 --- a/scripts/drivers/types/claude-code/type.conf +++ b/scripts/drivers/types/claude-code/type.conf @@ -4,6 +4,16 @@ template=template.md cli=claude spawnable=yes model_arg=--model +# Flag that sets a session's display name (#339): spawn passes `-n <team>-<agent>` +# so the session is born named after its role -- meaningful in the prompt box and +# the /resume picker, and the marker the resurrect hook keys on. Types without +# name_arg simply skip naming. +name_arg=-n +# Flag that resumes a session by id (#339): when a role has a recorded session +# whose transcript still exists, spawn passes `--resume <uuid>` so the role comes +# back into its prior context instead of booting fresh. Types without resume_arg +# always boot fresh. +resume_arg=--resume detect=CLAUDE_CODE_SESSION_ID detect_proc=claude claude-code claude-* # Session-identity vars a same-type spawn must NOT inherit, or the child mistakes diff --git a/scripts/drivers/types/codex/_transcript-exists.sh b/scripts/drivers/types/codex/_transcript-exists.sh new file mode 100644 index 00000000..5aa35f90 --- /dev/null +++ b/scripts/drivers/types/codex/_transcript-exists.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# codex driver hook: does a resumable rollout exist for <uuid>? (#339) +# +# Codex persists each session as a "rollout" file: +# ~/.codex/sessions/YYYY/MM/DD/rollout-<timestamp>-<session_uuid>.jsonl +# The session UUID is the trailing component of the filename and is exactly the +# SESSION_ID `codex resume <SESSION_ID>` accepts (verified: it equals the +# session_meta payload.id). Unlike claude-code, the layout is date-partitioned, +# not cwd-keyed, so <project> is not part of the lookup. +# +# This on-disk layout is CLI-internal, so the check lives in the codex driver, +# never in core. Every failure (unset HOME, missing dir, empty uuid) returns +# non-zero = "not found", so the resume gate fails open to a fresh session -- +# important because `codex resume <gone-uuid>` errors out ("no rollout found") +# rather than starting fresh (verified). +# +# Defines: agmsg_transcript_exists <uuid> <project> -> 0 if a rollout exists. + +agmsg_transcript_exists() { + local uuid="$1" sessions_dir + [ -n "$uuid" ] || return 1 + [ -n "${HOME:-}" ] || return 1 + sessions_dir="$HOME/.codex/sessions" + [ -d "$sessions_dir" ] || return 1 + find "$sessions_dir" -type f -name "rollout-*-$uuid.jsonl" 2>/dev/null | grep -q . +} diff --git a/scripts/drivers/types/codex/codex-bridge-launcher.sh b/scripts/drivers/types/codex/codex-bridge-launcher.sh index f11733c9..a06088f8 100755 --- a/scripts/drivers/types/codex/codex-bridge-launcher.sh +++ b/scripts/drivers/types/codex/codex-bridge-launcher.sh @@ -30,6 +30,16 @@ source "$SCRIPT_DIR/../../../lib/node.sh" NODE_BIN="$(agmsg_resolve_node)" TAB="$(printf '\t')" +# role-session record (#350): the bridge prefers this role's RECORDED codex thread +# over the app-server's "loaded" thread (see the thread-resolution block below). +# shellcheck source=../../../lib/role-session.sh +source "$SCRIPT_DIR/../../../lib/role-session.sh" +# shellcheck source=../../../lib/resolve-project.sh +source "$SCRIPT_DIR/../../../lib/resolve-project.sh" +# Canonicalize once so the record's project (stored from the codex actas flow's +# cwd) compares equal to this launcher's project even across a symlinked path. +PROJECT_PHYS="$(agmsg_canonical_path "$PROJECT" 2>/dev/null || printf '%s' "$PROJECT")" + mkdir -p "$RUN_DIR" resolve_identity() { # prints "team<TAB>name" lines for the project's codex roles @@ -61,6 +71,11 @@ log="$RUN_DIR/codex-bridge.$team.$name.log" # launcher instance can tell a bridge bound to a stale app-server (old port, # from before a codex upgrade) from one bound to the current server. See #197/#237. appserver_file="$RUN_DIR/codex-bridge.$team.$name.appserver" +# Records the thread a live bridge was bound to (#350), so a later launcher can +# rebind when the resolved thread changes -- e.g. once a role-session record +# appears for a bridge first launched on "loaded", it is torn down and relaunched +# on the recorded thread instead of clinging to the ambiguous "loaded" one. +thread_file="$RUN_DIR/codex-bridge.$team.$name.thread" # An explicit AGMSG_CODEX_BRIDGE_CMD is a complete runnable (tests, custom # wrappers) — run it as-is. Only the default codex-bridge.js is launched through # a resolved Node, since its env-node shebang fails where a version-manager Node @@ -89,6 +104,24 @@ EOF fi fi + # Prefer this role's RECORDED codex thread (#350). The app-server's "loaded" + # thread is whichever conversation the server last touched -- ambiguous when a + # cwd has run more than one codex thread, so a co-resident thread can capture + # this role's messages. The role-session record (#339) stores this role's own + # thread deterministically; use it when present AND recorded for THIS project. + # A request-file thread (above) still wins; no record -- or a record for a + # different project -- falls back to "loaded" (fail-open for roles predating the + # record). Freshness holds because a role re-runs actas on resume (#339), which + # rewrites the record with its current thread. + if [ "$thread_id" = "loaded" ]; then + rec_thread="$(agmsg_role_session_uuid "$team" "$name" 2>/dev/null || true)" + if [ -n "$rec_thread" ]; then + rec_project="$(agmsg_role_session_get "$team" "$name" project 2>/dev/null || true)" + rec_project_phys="$(agmsg_canonical_path "$rec_project" 2>/dev/null || printf '%s' "$rec_project")" + [ "$rec_project_phys" = "$PROJECT_PHYS" ] && thread_id="$rec_thread" + fi + fi + if [ -f "$pidfile" ]; then bridge_pid="$(cat "$pidfile" 2>/dev/null || true)" # bridge_pid is a Windows-native pid (codex-bridge.js's writeMeta() writes @@ -109,13 +142,19 @@ EOF # alive but delivers nothing. The bridge's own exit-on-close covers most of # this, but guard the race where the old bridge has not exited yet by the # time a new launcher re-checks: an app-server mismatch means tear it down. + # Reuse only when the live bridge is bound to BOTH the current app-server + # AND the current thread. The thread guard (#350) is what lets a bridge + # first launched on the ambiguous "loaded" thread rebind once this role's + # recorded thread becomes known -- otherwise the app-server match alone + # would keep the wrong-thread bridge alive indefinitely. bound_url="$(cat "$appserver_file" 2>/dev/null || true)" - if [ "$bound_url" = "$req_app_server" ]; then + bound_thread="$(cat "$thread_file" 2>/dev/null || true)" + if [ "$bound_url" = "$req_app_server" ] && [ "$bound_thread" = "$thread_id" ]; then sleep 0.3 continue fi kill "$bridge_pid" 2>/dev/null || true - rm -f "$pidfile" "$appserver_file" + rm -f "$pidfile" "$appserver_file" "$thread_file" fi fi @@ -130,5 +169,6 @@ EOF >>"$log" 2>&1 & # Record what this bridge is bound to so a later launcher can detect staleness. printf '%s' "$req_app_server" > "$appserver_file" + printf '%s' "$thread_id" > "$thread_file" sleep 1 done diff --git a/scripts/drivers/types/codex/codex-record-session.sh b/scripts/drivers/types/codex/codex-record-session.sh new file mode 100755 index 00000000..05438f3f --- /dev/null +++ b/scripts/drivers/types/codex/codex-record-session.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# codex-record-session.sh — record a codex role's resumable session (#339). +# +# claude-code records (team,agent)->session in actas-claim.sh. Codex actas is +# otherwise send-side only and never runs actas-claim, so without this a codex +# role would have no role-session record and could never be resumed (spawn would +# always boot it fresh). This is the codex-side equivalent: the codex actas flow +# calls it, and it writes the record so a later spawn/resume brings the role back +# into its thread. +# +# Usage: codex-record-session.sh <team> <agent> <project> +# +# Thread-id resolution — QUALITY GUARD (#339 review). The recorded id MUST be +# THIS session's codex thread, never another's: a resume mis-fire (resuming the +# wrong conversation) is worse than a fresh boot. So resolution is deliberately +# conservative and biased toward recording NOTHING when unsure (fresh = zero harm): +# 1. Prefer $CODEX_THREAD_ID -- exported on the interactive/--remote path, which +# is exactly the spawned-codex case this feature targets. Unambiguous. +# 2. Else fall back to a rollout whose session_meta cwd matches the project, but +# ONLY when that match is UNIQUE among recent rollouts. If two or more recent +# rollouts share this cwd (concurrent codex sessions in the same directory), +# we cannot tell which is ours -> record nothing. +# Always best-effort: every failure path is a silent no-op (exit 0). +set -uo pipefail + +TEAM="${1:-}"; AGENT="${2:-}"; PROJECT="${3:-}" +[ -n "$TEAM" ] && [ -n "$AGENT" ] && [ -n "$PROJECT" ] || exit 0 + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# types/codex/ -> up 4 (codex -> types -> drivers -> scripts -> skill root). +SKILL_DIR="$(cd "$SCRIPT_DIR/../../../.." && pwd)" +export SKILL_DIR +# shellcheck disable=SC1091 +. "$SKILL_DIR/scripts/lib/resolve-project.sh" +# shellcheck disable=SC1091 +. "$SKILL_DIR/scripts/lib/storage.sh" +# shellcheck disable=SC1091 +. "$SKILL_DIR/scripts/lib/role-session.sh" + +thread="" +if [ -n "${CODEX_THREAD_ID:-}" ]; then + thread="$CODEX_THREAD_ID" +else + # ${HOME:-} so an unset HOME under `set -u` is a silent no-op (empty -> the + # dir check below fails -> fresh), not an unbound-variable abort (co1 nit). + sessions_dir="${HOME:-}/.codex/sessions" + if [ -n "${HOME:-}" ] && [ -d "$sessions_dir" ]; then + project_phys="$(agmsg_canonical_path "$PROJECT")" + # Distinct thread ids whose session_meta cwd (canonicalized -- codex records + # the physical cwd while agmsg may hold a symlinked path, #160) matches the + # project, among the most recent rollouts. Exactly one => unambiguously ours. + # + # The matching loop writes ids to a temp file rather than an outer + # tids="$( ... while ... )" capture: bash 3.2 (macOS) cannot parse a while + # loop that itself contains $(...) command substitutions when the whole thing + # is wrapped in another $() -- the nested-substitution parser mis-tracks and + # errors. Writing to a file keeps every $(...) un-nested (the same shape the + # existing agmsg_resolve_codex_thread uses). + tids_file="$(mktemp "${TMPDIR:-/tmp}/agmsg-codexrec.XXXXXX" 2>/dev/null || true)" + if [ -n "$tids_file" ]; then + find "$sessions_dir" -type f -name 'rollout-*.jsonl' 2>/dev/null | sort -r | head -40 \ + | while IFS= read -r f; do + [ -f "$f" ] || continue + first="$(head -1 "$f" 2>/dev/null)" + case "$first" in *'"session_meta"'*) ;; *) continue ;; esac + esc="$(printf '%s' "$first" | sed "s/'/''/g")" + cwd="$(agmsg_sqlite_mem "SELECT COALESCE(json_extract('$esc','\$.payload.cwd'),'')" 2>/dev/null)" + [ -n "$cwd" ] || continue + [ "$(agmsg_canonical_path "$cwd")" = "$project_phys" ] || continue + agmsg_sqlite_mem "SELECT COALESCE(json_extract('$esc','\$.payload.id'),'')" 2>/dev/null + done | grep . | sort -u > "$tids_file" + # Exactly one distinct matching id => unambiguously ours. 0 (nothing) or + # >1 (concurrent codex sessions in this cwd -> ambiguous) => record nothing. + if [ "$(grep -c . "$tids_file" 2>/dev/null || echo 0)" -eq 1 ]; then + thread="$(head -1 "$tids_file")" + fi + rm -f "$tids_file" + fi + fi +fi + +[ -n "$thread" ] || exit 0 +# codex thread ids are already bare UUIDs (no composite pid form), so record as-is. +agmsg_role_session_record "$TEAM" "$AGENT" "$thread" "$PROJECT" codex || true +exit 0 diff --git a/scripts/drivers/types/codex/codex-shim.sh b/scripts/drivers/types/codex/codex-shim.sh index e1927c47..4d38edd1 100755 --- a/scripts/drivers/types/codex/codex-shim.sh +++ b/scripts/drivers/types/codex/codex-shim.sh @@ -133,7 +133,7 @@ monitor_cmd="${AGMSG_CODEX_MONITOR_CMD:-$SCRIPT_DIR/codex-monitor.sh}" case "$command_name" in "") - AGMSG_REAL_CODEX="$real_codex" exec "$monitor_cmd" --project "$project" --codex-command codex -- + AGMSG_REAL_CODEX="$real_codex" exec "$monitor_cmd" --project "$project" --codex-command codex -- "$@" ;; resume) monitor_args=() diff --git a/scripts/drivers/types/codex/template.md b/scripts/drivers/types/codex/template.md index c673f279..8dcf1195 100644 --- a/scripts/drivers/types/codex/template.md +++ b/scripts/drivers/types/codex/template.md @@ -112,7 +112,8 @@ If argument starts with "actas" followed by an agent name (e.g. "actas alice"): 2. Run `~/.agents/skills/__SKILL_NAME__/scripts/identities.sh "$(pwd)" codex` to see whether the role is already registered for this (project, type). 3. If the name does not appear in the output, join under the existing team. For a single team, run `~/.agents/skills/__SKILL_NAME__/scripts/join.sh <team> <name> codex "$(pwd)"`. For multiple teams, ask the user which team to join the new role into. 4. Set the session's active FROM to `<name>` for every `send.sh` call until another `actas`. -5. Tell the user: "Now acting as `<name>`. Sends will use `<name>` as the from agent. (Codex has no Monitor tool, so receive still covers all of your registered roles in this project.)" +5. Record this session as the role's seat so it can be resumed later (best-effort): determine which team `<name>` belongs to (from the identities output / the join above), then run `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/codex-record-session.sh <team> <name> "$(pwd)"`. It writes the record only when this session's codex thread id is unambiguous; otherwise it records nothing and the role simply boots fresh next time (no harm). This is what lets a later `spawn <role>` bring the role back into this conversation. +6. Tell the user: "Now acting as `<name>`. Sends will use `<name>` as the from agent. (Codex has no Monitor tool, so receive still covers all of your registered roles in this project.)" If argument starts with "drop" followed by an agent name (e.g. "drop alice"): 1. Parse the role name. diff --git a/scripts/drivers/types/codex/type.conf b/scripts/drivers/types/codex/type.conf index 33631abb..ce35a1ea 100644 --- a/scripts/drivers/types/codex/type.conf +++ b/scripts/drivers/types/codex/type.conf @@ -3,6 +3,13 @@ name=codex template=template.md cli=codex spawnable=yes +# Resume a prior session (#339). codex 0.142 resumes via a SUBCOMMAND, not a flag: +# `codex resume <SESSION_ID> [PROMPT]`. The one-key convention emits this value +# verbatim right after the cli (subcommands must lead the argv), so `resume` is +# the whole token. No name_arg: codex has no session display-name flag, so its +# sessions are not named <team>-<agent> (spawn/despawn resume still works via the +# recorded thread id; tmux-resurrect title matching does not apply to codex). +resume_arg=resume # codex invokes a skill with "$", not Claude Code's "/" (see spawn.sh, #283). cmd_prefix=$ model_arg=-m diff --git a/scripts/drivers/types/gemini/type.conf b/scripts/drivers/types/gemini/type.conf index 4810ca7c..cbf7874b 100644 --- a/scripts/drivers/types/gemini/type.conf +++ b/scripts/drivers/types/gemini/type.conf @@ -6,7 +6,7 @@ spawnable=yes # gemini invokes a skill with "$", not Claude Code's "/" (see spawn.sh, #283). cmd_prefix=$ model_arg=--model -detect=GEMINI_API_KEY GOOGLE_GEMINI_CLI +detect=GEMINI_CLI GEMINI_API_KEY detect_proc=gemini gemini-* hooks_file=.agent/rules/agmsg.md monitor=no diff --git a/scripts/internal/resurrect-panes.sh b/scripts/internal/resurrect-panes.sh new file mode 100755 index 00000000..7f109d8d --- /dev/null +++ b/scripts/internal/resurrect-panes.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +# resurrect-panes.sh — tmux-resurrect post-restore seat assignment (#339). +# +# Wire it up in tmux.conf as the restore-time hook (NOT @resurrect-processes): +# set -g @resurrect-hook-post-restore-all '<skill>/scripts/internal/resurrect-panes.sh' +# +# Why a hook and not @resurrect-processes: a verbatim argv re-run is wrong BOTH +# ways. A pane saved with the fresh-boot argv (`claude -n <name> "/agmsg actas +# <name>"`) would re-run as ANOTHER brand-new session; a pane saved with a +# name-based resume argv (`claude --resume <name>`) stalls on the interactive +# picker. So we let resurrect restore panes as plain shells, then this hook +# resolves each role's CURRENT session at restore time and relaunches it via the +# same resume-or-fresh command spawn builds (shared lib/boot-command.sh). +# +# For each restored pane whose saved title or argv carries a role's <team>-<agent> +# name, it looks up the role-session record (type/team/agent/uuid), builds the +# boot command, and send-keys it into that exact pane -- but only if the pane +# came back as a plain shell (never clobber a pane already running a program). +# +# Fail-open throughout: no save file, no records, tmux absent, an unparseable +# line, or a pane running something -> silent skip. Safe to run repeatedly. +# +# The parse + command-construction core is agmsg_resurrect_plan (pure: reads the +# save file + records, emits "<target>\t<command>" lines, touches no tmux), so it +# is unit-testable against a fixture. The live send-keys wrapper runs only when +# this file is executed directly. + +set -uo pipefail + +_RP_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +SKILL_DIR="${SKILL_DIR:-$(cd "$_RP_SCRIPT_DIR/../.." && pwd)}" +export SKILL_DIR +# shellcheck disable=SC1091 +. "$SKILL_DIR/scripts/lib/type-registry.sh" +# shellcheck disable=SC1091 +. "$SKILL_DIR/scripts/lib/role-session.sh" +# shellcheck disable=SC1091 +. "$SKILL_DIR/scripts/lib/boot-command.sh" + +# Locate the newest resurrect save file: the `last` symlink under the XDG path, +# then the legacy ~/.tmux path. AGMSG_RESURRECT_SAVE overrides (used by tests). +agmsg_resurrect_save_file() { + local f + for f in "${AGMSG_RESURRECT_SAVE:-}" \ + "${HOME:-}/.local/share/tmux/resurrect/last" \ + "${HOME:-}/.tmux/resurrect/last"; do + [ -n "$f" ] && [ -e "$f" ] && { printf '%s' "$f"; return 0; } + done + return 1 +} + +# Trim a saved pane_title to its role-name candidate: drop an optional leading +# ':' (some resurrect versions prefix preservable fields) and a leading activity +# glyph + space (tmux may render e.g. "* name"). Returns the trailing token. +_agmsg_rp_title_candidate() { + local t="${1#:}" + printf '%s' "${t##* }" +} + +# Emit "<tmux-target>\t<boot-command>" for every save-file pane that is a role's +# seat. Pure: no tmux calls, no live-pane check -- the caller decides whether to +# actually seat each target. Reads role-session records from $SKILL_DIR/run. +agmsg_resurrect_plan() { + local save="$1" + [ -n "$save" ] && [ -f "$save" ] || return 0 + + # Load every role-session record once into parallel arrays. Small N (one per + # role ever actas'd on this host). + local run_dir="$SKILL_DIR/run" rec n ty tm ag + local -a names=() types=() teams=() agents=() + if [ -d "$run_dir" ]; then + for rec in "$run_dir"/role-session.*; do + [ -f "$rec" ] || continue + n="$(sed -n 's/^name=//p' "$rec" 2>/dev/null | head -1)" + [ -n "$n" ] || continue + ty="$(sed -n 's/^type=//p' "$rec" 2>/dev/null | head -1)" + tm="$(sed -n 's/^team=//p' "$rec" 2>/dev/null | head -1)" + ag="$(sed -n 's/^agent=//p' "$rec" 2>/dev/null | head -1)" + names+=("$n"); types+=("$ty"); teams+=("$tm"); agents+=("$ag") + done + fi + [ "${#names[@]}" -gt 0 ] || return 0 + + # tmux-resurrect pane line (tab-separated), observed layout: + # 1 marker(pane) 2 session 3 window_index 4 window_active 5 window_flags + # 6 pane_index 7 pane_title 8 :current_path 9 pane_active + # 10 pane_current_command 11 :pane_full_command + local marker session windex wactive wflags pindex ptitle cpath pactive ccmd fullcmd + local title_cand i rn rty narg idx target proj uuid prompt cli line + while IFS=$'\t' read -r marker session windex wactive wflags pindex ptitle cpath pactive ccmd fullcmd; do + [ "$marker" = "pane" ] || continue + [ -n "$session" ] && [ -n "$windex" ] && [ -n "$pindex" ] || continue + fullcmd="${fullcmd#:}" + title_cand="$(_agmsg_rp_title_candidate "$ptitle")" + + # Match this pane to a role by name -- via the saved title, or the `-n <name>` + # role marker in the saved argv (name_arg from the role's type). Match on the + # whole name (never split <team>-<agent> on '-'). + idx=-1; i=0 + while [ "$i" -lt "${#names[@]}" ]; do + rn="${names[$i]}"; rty="${types[$i]}" + narg="$(agmsg_type_get "$rty" name_arg 2>/dev/null || true)" + if [ -n "$rn" ] && [ "$title_cand" = "$rn" ]; then + idx="$i"; break + fi + if [ -n "$narg" ] && [ -n "$rn" ]; then + case " $fullcmd " in + *" $narg $rn "*) idx="$i"; break ;; + esac + fi + i=$((i + 1)) + done + [ "$idx" -ge 0 ] || continue + + # Skip a role whose actas lock is held by a LIVE session (mirrors spawn's + # pre-flight). The role is already seated elsewhere -- its record may have + # been sown from a still-running session in another process, and resuming its + # uuid here would double-launch, which the CLI rejects, killing the pane back + # to a bare shell. "the pane restored as a shell" alone can't catch this; the + # lock is the source of truth for "is this role's owner alive right now". A + # dead owner leaves a stale lock -> actas_lock_state reports free -> we reseat. + lockstate="$(actas_lock_state "${teams[$idx]}" "${agents[$idx]}" '' 2>/dev/null || echo free)" + case "$lockstate" in other:*) continue ;; esac + + rty="${types[$idx]}" + cli="$(agmsg_type_get "$rty" cli 2>/dev/null || true)" + [ -n "$cli" ] || continue + proj="${cpath#:}" + uuid="$(agmsg_role_resume_uuid "$rty" "${teams[$idx]}" "${agents[$idx]}" "$proj" 2>/dev/null || true)" + prompt="$(agmsg_actas_prompt "$rty" "${agents[$idx]}")" + # Resume head right after the cli (cli-immediately-after convention), then + # the name/prompt tail -- same order spawn emits. + line="$cli$(agmsg_role_resume_head "$rty" "$uuid")$(agmsg_role_cli_args "$rty" "${names[$idx]}" "$prompt")" + target="${session}:${windex}.${pindex}" + printf '%s\t%s\n' "$target" "$line" + done < "$save" + return 0 +} + +# Is the live pane <target> sitting at a plain shell (safe to seat)? +_agmsg_rp_pane_is_shell() { + local target="$1" cmd + cmd="$(tmux display -p -t "$target" '#{pane_current_command}' 2>/dev/null || true)" + case "${cmd##*/}" in + ''|bash|zsh|sh|fish|dash|ksh|tcsh|csh|-bash|-zsh|-sh) return 0 ;; + *) return 1 ;; + esac +} + +# Live entry point: build the plan, then seat each target that restored as a +# plain shell. Fail-open: no tmux -> nothing to do. +agmsg_resurrect_run() { + command -v tmux >/dev/null 2>&1 || return 0 + local save target cmd + save="$(agmsg_resurrect_save_file)" || return 0 + while IFS=$'\t' read -r target cmd; do + [ -n "$target" ] && [ -n "$cmd" ] || continue + _agmsg_rp_pane_is_shell "$target" || continue + tmux send-keys -t "$target" "$cmd" Enter 2>/dev/null || true + done < <(agmsg_resurrect_plan "$save") + return 0 +} + +# Run only when executed directly (so tests can source for agmsg_resurrect_plan). +if [ "${BASH_SOURCE[0]:-$0}" = "$0" ]; then + agmsg_resurrect_run +fi diff --git a/scripts/join.sh b/scripts/join.sh index dcf76381..1888a65a 100755 --- a/scripts/join.sh +++ b/scripts/join.sh @@ -43,7 +43,14 @@ source "$SCRIPT_DIR/lib/resolve-project.sh" source "$SCRIPT_DIR/lib/storage.sh" # shellcheck disable=SC1091 source "$SCRIPT_DIR/lib/registry-lock.sh" -PROJECT_PATH="$(agmsg_resolve_project "$PROJECT_PATH" "$AGENT_TYPE")" +# Scope resolution to the join target team (#357): a poison registration in an +# unrelated team must not steer this join's ancestor/git-common fallback. +# Registering a project AT $HOME or / is deliberately allowed -- both claude and +# codex support sessions whose cwd is $HOME, so starting a project there is a +# legitimate use case. The #357 protection is on the resolution side: the +# ancestor walk never LANDS on $HOME/`/`, so such a registration only ever +# matches its exact path and cannot silently vacuum up sessions beneath it. +PROJECT_PATH="$(agmsg_resolve_project "$PROJECT_PATH" "$AGENT_TYPE" "$TEAM")" PROJECT_PATH="$(agmsg_normalize_project_path "$PROJECT_PATH")" TEAM_CONFIG="$TEAMS_DIR/$TEAM/config.json" diff --git a/scripts/lib/boot-command.sh b/scripts/lib/boot-command.sh new file mode 100644 index 00000000..edd3f22d --- /dev/null +++ b/scripts/lib/boot-command.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# boot-command.sh — shared construction of a role's resume-or-fresh boot command. +# +# Two launchers bring a role up: spawn.sh (a new/resumed role in a fresh +# terminal/pane) and resurrect-panes.sh (relaunch a role into its tmux pane after +# a server restart). Centralizing the pieces here keeps them from drifting on +# flag order or the resume-vs-fresh gate. +# +# Requires: SKILL_DIR set; type-registry.sh and role-session.sh sourced by the +# caller (for agmsg_type_get / agmsg_type_dir / agmsg_role_session_uuid). + +[ -n "${_AGMSG_BOOT_COMMAND_SH:-}" ] && return 0 +_AGMSG_BOOT_COMMAND_SH=1 + +: "${SKILL_DIR:?boot-command.sh requires SKILL_DIR}" + +# The actas prompt a booted role runs as its first input: +# <cmd_prefix><cmd_name> actas <agent> +# cmd_name is the installed command (skill dir basename, honoring a custom +# install name); cmd_prefix is '/' for Claude Code slash commands and '$' for +# agentskills CLIs (type.conf cmd_prefix=). Re-running actas is what re-arms a +# resumed session's watcher, exclusivity lock, and active FROM. +agmsg_actas_prompt() { + local type="$1" agent="$2" cmd_name cmd_prefix + cmd_name="$(basename "$SKILL_DIR")" + cmd_prefix="$(agmsg_type_get "$type" cmd_prefix)" + [ -n "$cmd_prefix" ] || cmd_prefix="/" + printf '%s%s actas %s' "$cmd_prefix" "$cmd_name" "$agent" +} + +# Resolve the resumable session id for a role, or print nothing when it should +# boot fresh. Fail-open at every gate: force-fresh, no resume_arg, no record, no +# transcript-existence driver hook, or a stale record whose transcript is gone +# all yield empty (=> fresh). <force_fresh> non-zero forces empty. +agmsg_role_resume_uuid() { + local type="$1" team="$2" agent="$3" project="$4" force_fresh="${5:-0}" + local resume_arg cand tdir + [ "$force_fresh" = 0 ] || return 0 + resume_arg="$(agmsg_type_get "$type" resume_arg)" + [ -n "$resume_arg" ] || return 0 + cand="$(agmsg_role_session_uuid "$team" "$agent" 2>/dev/null || true)" + [ -n "$cand" ] || return 0 + # The on-disk transcript layout is CLI-internal, so the existence check lives + # in the type driver (scripts/drivers/types/<type>/_transcript-exists.sh), + # never here. Absent hook => cannot verify => fresh. + tdir="$(agmsg_type_dir "$type" 2>/dev/null || true)" + { [ -n "$tdir" ] && [ -f "$tdir/_transcript-exists.sh" ]; } || return 0 + # shellcheck disable=SC1090 + . "$tdir/_transcript-exists.sh" + command -v agmsg_transcript_exists >/dev/null 2>&1 || return 0 + agmsg_transcript_exists "$cand" "$project" || return 0 + printf '%s' "$cand" +} + +# Emit the resume HEAD for <type>: the manifest `resume_arg` value followed by +# <resume_uuid>, or nothing when the uuid is empty (=> fresh boot). Space-prefixed; +# resume_arg is emitted VERBATIM (bare manifest data), the uuid %q-quoted. +# +# One-key, cli-immediately-after convention: a single manifest key `resume_arg` +# carries the resume token in whatever shape the CLI wants -- a FLAG +# ('--resume', claude-code) or a SUBCOMMAND ('resume', codex 0.142's +# `codex resume <id> [prompt]`). Callers MUST emit this head right after the cli +# binary and before any other args. That position is mandatory for the subcommand +# shape (a subcommand must lead the argv) and harmless for the flag shape (flags +# are order-independent), so one emission order serves both -- no per-shape branch +# and no second manifest key. +agmsg_role_resume_head() { + local type="$1" resume_uuid="$2" resume_arg + [ -n "$resume_uuid" ] || return 0 + resume_arg="$(agmsg_type_get "$type" resume_arg)" + [ -n "$resume_arg" ] || return 0 + printf ' %s %q' "$resume_arg" "$resume_uuid" +} + +# Emit the role-identity TAIL for <type>: [name_arg <session_name>] [prompt_arg] +# <prompt>. Space-prefixed; flags are bare manifest data, values are %q-quoted. +# The caller has already emitted the cli, the resume head (agmsg_role_resume_head), +# and -- for spawn -- model + spawn-options. The resume head is separate because +# it must sit right after the cli (see the convention above); name/prompt are +# order-independent and live here. +agmsg_role_cli_args() { + local type="$1" session_name="$2" prompt="$3" + local name_arg prompt_arg + name_arg="$(agmsg_type_get "$type" name_arg)" + prompt_arg="$(agmsg_type_get "$type" prompt_arg)" + [ -n "$name_arg" ] && printf ' %s %q' "$name_arg" "$session_name" + [ -n "$prompt_arg" ] && printf ' %s' "$prompt_arg" + printf ' %q' "$prompt" +} diff --git a/scripts/lib/instance-id.sh b/scripts/lib/instance-id.sh index 60508c9a..90f5b1ea 100644 --- a/scripts/lib/instance-id.sh +++ b/scripts/lib/instance-id.sh @@ -73,6 +73,21 @@ agmsg_instance_is_composite() { esac } +# Extract the bare session_id from an instance id <token>: strips the trailing +# ".<pid>" of a composite "<sid>.<pid>"; a bare "<sid>" is returned unchanged. +# The bare sid is the identity that is STABLE across resume generations (the +# enclosing pid changes on each resume, the session_id does not), so role→ +# session records key on it rather than on the composite instance id — see +# role-session.sh. +agmsg_instance_bare_sid() { + local token="$1" + if agmsg_instance_is_composite "$token"; then + printf '%s' "${token%.*}" + else + printf '%s' "$token" + fi +} + # Derive an instance id for <session_id> from the enclosing agent <type>. # Resolves the agent pid via agmsg_agent_pid; on failure falls back to the bare # session_id and emits a one-line stderr warning. The fallback is a known diff --git a/scripts/lib/resolve-project.sh b/scripts/lib/resolve-project.sh index 711d9a7a..b4ede294 100644 --- a/scripts/lib/resolve-project.sh +++ b/scripts/lib/resolve-project.sh @@ -194,10 +194,25 @@ agmsg_project_sql_in_list() { } agmsg_find_registered_project_variant() { - local projects="$1" path="$2" candidate match + local projects="$1" path="$2" candidate match match_norm home_norm + # #357: never resolve to `/` or $HOME. They are ancestors of nearly every + # directory, so one stray registration there (even in another team, or a + # leftover) would capture unrelated sessions -- the ancestor walk climbs + # through them and would otherwise MATCH them, silently rewriting a project to + # $HOME. The walk may still ASCEND through these dirs; it just must not land on + # one. (marker resolution, step 1, is unaffected.) + # + # The exclusion compares NORMALIZED paths, so a registration stored with a + # trailing/duplicate slash ("$HOME/", "//") -- which raw string compare would + # miss -- is still excluded (agmsg_normalize_project_path collapses/strips + # those). Normalization is logical, not symlink-resolving; see the PR notes. + home_norm="$(agmsg_normalize_project_path "${HOME:-}" 2>/dev/null || true)" while IFS= read -r candidate; do match=$(printf '%s\n' "$projects" | grep -Fx -- "$candidate" | head -n 1 || true) if [ -n "$match" ]; then + match_norm="$(agmsg_normalize_project_path "$match" 2>/dev/null || printf '%s' "$match")" + case "$match_norm" in "/"|""|[A-Za-z]:|[A-Za-z]:/) continue ;; esac + { [ -n "$home_norm" ] && [ "$match_norm" = "$home_norm" ]; } && continue printf '%s' "$match" return 0 fi @@ -308,16 +323,26 @@ agmsg_marker_gc_stale() { done } -# List distinct registered project paths for <type>, one per line. +# List distinct registered project paths for <type>, one per line. An optional +# <team> scopes the scan to that team's config only (#357): a poison registration +# in an unrelated team must not leak into this team's resolution. Omitting <team> +# keeps the legacy all-teams scan for callers that don't know the target team +# (whoami/actas/watch) — a deliberate phased migration. agmsg_registered_projects() { - local type="$1" teams_dir="$SKILL_DIR/teams" config_file cfg_sql type_sql + local type="$1" team="${2:-}" teams_dir="$SKILL_DIR/teams" config_file cfg_sql type_sql [ -d "$teams_dir" ] || return 0 # Read config.json inside SQL via readfile() rather than binding it through a # `.param set` dot-command — the sqlite3 shell tokenizer doesn't honour SQL '' # escaping, so a config value with a single quote breaks the bind (#112). The # path and type are interpolated as SQL string literals with '' doubling. type_sql=$(printf '%s' "$type" | sed "s/'/''/g") - for config_file in "$teams_dir"/*/config.json; do + local -a configs + if [ -n "$team" ]; then + configs=("$teams_dir/$team/config.json") + else + configs=("$teams_dir"/*/config.json) + fi + for config_file in ${configs[@]+"${configs[@]}"}; do [ -f "$config_file" ] || continue cfg_sql=$(agmsg_sql_readfile_path "$config_file") sqlite3 :memory: " @@ -345,7 +370,7 @@ agmsg_registered_projects() { # dir (the git checkout itself is then unregistered, so we decline and let the # ancestor walk handle it). return 1 when git is absent or nothing matches. agmsg_gitcommon_project() { - local start="$1" type="$2" common main projects match + local start="$1" type="$2" team="${3:-}" common main projects match command -v git >/dev/null 2>&1 || return 1 [ -d "$start" ] || return 1 common=$(cd "$start" 2>/dev/null && git rev-parse --git-common-dir 2>/dev/null) || return 1 @@ -353,7 +378,7 @@ agmsg_gitcommon_project() { # --git-common-dir may be relative to <start>; make it absolute. case "$common" in /*) ;; *) common="$start/$common" ;; esac main=$(cd "$(dirname "$common")" 2>/dev/null && pwd) || return 1 - projects="$(agmsg_registered_projects "$type")" + projects="$(agmsg_registered_projects "$type" "$team")" [ -n "$projects" ] || return 1 match="$(agmsg_find_registered_project_variant "$projects" "$main")" || return 1 printf '%s' "$match" @@ -362,8 +387,8 @@ agmsg_gitcommon_project() { # Echo the nearest ancestor of <start> (inclusive) that is a registered project # for <type>. return 1 when none matches. agmsg_ancestor_project() { - local start="$1" type="$2" projects d next match - projects="$(agmsg_registered_projects "$type")" + local start="$1" type="$2" team="${3:-}" projects d next match + projects="$(agmsg_registered_projects "$type" "$team")" [ -n "$projects" ] || return 1 d="$(agmsg_normalize_project_path "$start")" while [ -n "$d" ] && [ "$d" != "." ]; do @@ -381,9 +406,14 @@ agmsg_ancestor_project() { } # Resolve the real project root for a slash-command invocation. -# Usage: agmsg_resolve_project <pwd_path> <type> +# Usage: agmsg_resolve_project <pwd_path> <type> [team] +# An optional <team> scopes the registry-based fallbacks (ancestor / git-common) +# to that team, so a poison registration in another team can't capture this +# resolution (#357). join.sh passes it (the join target team is known); +# team-agnostic callers (whoami/actas/watch/reset) omit it and keep the legacy +# all-teams behavior. agmsg_resolve_project() { - local pwd_path="$1" type="$2" pid marker anc gitc + local pwd_path="$1" type="$2" team="${3:-}" pid marker anc gitc # Explicit opt-out: caller passed a deliberate, possibly-unregistered path. if [ "${AGMSG_RESOLVE_PROJECT:-1}" = "0" ]; then printf '%s' "$pwd_path"; return 0 @@ -397,12 +427,12 @@ agmsg_resolve_project() { fi # 2) Nearest registered ancestor of pwd (git-independent; covers nested # subdirs and worktrees that live under the registered project). - if anc="$(agmsg_ancestor_project "$pwd_path" "$type")" && [ -n "$anc" ]; then + if anc="$(agmsg_ancestor_project "$pwd_path" "$type" "$team")" && [ -n "$anc" ]; then printf '%s' "$anc"; return 0 fi # 3) Registered main checkout of pwd's git repo (recovers a SIBLING worktree # the ancestor walk cannot reach). Validated against the registry. - if gitc="$(agmsg_gitcommon_project "$pwd_path" "$type")" && [ -n "$gitc" ]; then + if gitc="$(agmsg_gitcommon_project "$pwd_path" "$type" "$team")" && [ -n "$gitc" ]; then printf '%s' "$gitc"; return 0 fi # 4) Unchanged fallback. diff --git a/scripts/lib/role-session.sh b/scripts/lib/role-session.sh new file mode 100644 index 00000000..084fc2ed --- /dev/null +++ b/scripts/lib/role-session.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +# role-session.sh — advisory (team, agent) -> session record. +# +# A role (team, agent) has no durable link to the CLI session that embodies it: +# spawn always boots fresh, and a resumed session can only be found by guessing +# in the picker. This file records the LATEST session id that held each role, so +# a boot wrapper (#339 PR-C) can resume a role back into its prior conversational +# context, and the tmux-resurrect hook (PR-D) / role-aware SessionStart (PR-E) +# can reverse-map a pane / session id back to its role. +# +# Design notes: +# - The record stores the BARE session id (agmsg_instance_bare_sid), which is +# stable across resume generations — NOT the composite instance id (#93) the +# actas lock keys on. The lock answers "who owns this role right now"; this +# record answers "what session last embodied it" (resumable identity). +# - It is ADVISORY runtime state, not config: written/read only by this lib and +# its consumers, safe to delete at any time. Every write is best-effort — a +# failed record write must NEVER fail the caller (the claim). Every read is +# fail-open — a missing/unreadable record yields empty (→ fresh boot). +# - Filenames follow the actas-lock convention exactly: run/role-session.<t>__<a> +# with the SAME percent-encoding sanitizer, so these files sit next to the +# actas.<t>__<a>.session locks and encode names identically (unicode-safe). +# +# Required caller-set variable: +# SKILL_DIR — agmsg skill root. + +# Guard against double-source (actas-claim.sh sources both this and actas-lock.sh). +[ -n "${_AGMSG_ROLE_SESSION_SH:-}" ] && return 0 +_AGMSG_ROLE_SESSION_SH=1 + +: "${SKILL_DIR:?role-session.sh requires SKILL_DIR}" + +# Reuse the actas-lock filename sanitizer (_actas_lock_encode) and run/ dir +# (_actas_lock_dir) rather than reimplementing them — the two families of state +# files must agree on encoding. Source it only if not already present so callers +# that already sourced actas-lock.sh (e.g. actas-claim.sh) don't re-run it. +if ! command -v _actas_lock_encode >/dev/null 2>&1; then + # shellcheck disable=SC1091 + . "$SKILL_DIR/scripts/lib/instance-id.sh" + # shellcheck disable=SC1091 + . "$SKILL_DIR/scripts/lib/actas-lock.sh" +fi + +# Compute the record file path for (team, agent). Same encoding + dir as the +# actas lock, distinct prefix (role-session. vs actas.), no .session suffix. +_agmsg_role_session_path() { + local team="$1" agent="$2" t a + t="$(_actas_lock_encode "$team")"; a="$(_actas_lock_encode "$agent")" + printf '%s/role-session.%s__%s' "$(_actas_lock_dir)" "$t" "$a" +} + +# Record (team, agent) -> <bare_sid> for <project>. Latest session wins +# (overwrites any prior record). Best-effort: returns 0 on every path, including +# every failure, so a caller can `agmsg_role_session_record ... || true` purely +# for readability — a failed write never propagates. Written atomically via a +# tmp file + rename so a concurrent reader never sees a half-written record. +# +# Fields (key=value, one per line): +# session=<bare_sid> resumable session identity (stable across resume) +# name=<team>-<agent> the -n display name; whole, so reverse lookup never +# has to split <team>-<agent> apart (either half may +# itself contain '-') +# team=<team> stored explicitly so by-sid consumers (PR-E) recover +# agent=<agent> the role without re-parsing the joined name +# type=<type> the agent type (claude-code/...); the resurrect hook +# (PR-D) needs it to rebuild the role's boot command +# from the type manifest. Empty when unknown. +# project=<project> the resolved project root +# updated_at=<iso8601> best-effort timestamp (empty if date(1) unavailable) +agmsg_role_session_record() { + local team="$1" agent="$2" bare_sid="$3" project="${4:-}" type="${5:-}" + [ -n "$team" ] && [ -n "$agent" ] && [ -n "$bare_sid" ] || return 0 + local path dir tmp ts + path="$(_agmsg_role_session_path "$team" "$agent")" || return 0 + dir="$(_actas_lock_dir)" + mkdir -p "$dir" 2>/dev/null || return 0 + tmp="$(mktemp "$dir/.role-session.XXXXXX" 2>/dev/null)" || return 0 + ts="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || true)" + { + printf 'session=%s\n' "$bare_sid" + printf 'name=%s-%s\n' "$team" "$agent" + printf 'team=%s\n' "$team" + printf 'agent=%s\n' "$agent" + printf 'type=%s\n' "$type" + printf 'project=%s\n' "$project" + printf 'updated_at=%s\n' "$ts" + } > "$tmp" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; return 0; } + mv -f "$tmp" "$path" 2>/dev/null || rm -f "$tmp" 2>/dev/null + return 0 +} + +# Read a single field from a role's record by (team, agent). Empty if absent. +# Convenience getter used by consumers that need one field (e.g. the resurrect +# hook reading `type`); mirrors agmsg_role_session_uuid's read of `session`. +agmsg_role_session_get() { + local team="$1" agent="$2" key="$3" path + path="$(_agmsg_role_session_path "$team" "$agent")" || return 0 + _agmsg_role_session_field "$path" "$key" +} + +# Read a single field from a record file. Empty if file/field absent. +_agmsg_role_session_field() { + local path="$1" key="$2" + [ -f "$path" ] || return 0 + # First match only; value is everything after the first '='. + sed -n "s/^${key}=//p" "$path" 2>/dev/null | head -1 +} + +# Read back the recorded bare session id for (team, agent). Empty if no record. +# This is the primary getter used by the boot wrapper (PR-C). +agmsg_role_session_uuid() { + local team="$1" agent="$2" path + path="$(_agmsg_role_session_path "$team" "$agent")" || return 0 + _agmsg_role_session_field "$path" session +} + +# Scan run/role-session.* for the record whose name= field equals <name> and +# print its full body (all key=value lines). Empty if none. Matches on the whole +# name= field (never by splitting on '-'), per the record's raison d'etre. +# Used by the resurrect hook (PR-D) to map a pane's `-n <name>` back to a uuid. +agmsg_role_session_lookup_by_name() { + local name="$1" dir f v + [ -n "$name" ] || return 0 + dir="$(_actas_lock_dir)" + [ -d "$dir" ] || return 0 + for f in "$dir"/role-session.*; do + [ -f "$f" ] || continue + v="$(_agmsg_role_session_field "$f" name)" + if [ "$v" = "$name" ]; then + cat "$f" 2>/dev/null || true + return 0 + fi + done + return 0 +} + +# Scan run/role-session.* for the record whose session= field equals <sid> and +# print its full body (all key=value lines). Empty if none. Used by role-aware +# SessionStart (PR-E) to map a resumed session id back to its (team, agent). +agmsg_role_session_lookup_by_sid() { + local sid="$1" dir f v + [ -n "$sid" ] || return 0 + dir="$(_actas_lock_dir)" + [ -d "$dir" ] || return 0 + for f in "$dir"/role-session.*; do + [ -f "$f" ] || continue + v="$(_agmsg_role_session_field "$f" session)" + if [ "$v" = "$sid" ]; then + cat "$f" 2>/dev/null || true + return 0 + fi + done + return 0 +} diff --git a/scripts/send.sh b/scripts/send.sh index b7a2af1d..63312536 100755 --- a/scripts/send.sh +++ b/scripts/send.sh @@ -1,12 +1,16 @@ #!/usr/bin/env bash set -euo pipefail -# Usage: send.sh <team> <from> <to> <message> +# Usage: send.sh <team> <from> <to> <message> [--force] -TEAM="${1:?Usage: send.sh <team> <from> <to> <message>}" +TEAM="${1:?Usage: send.sh <team> <from> <to> <message> [--force]}" FROM="${2:?Missing from agent}" TO="${3:?Missing to agent}" BODY="${4:?Missing message body}" +FORCE=0 +if [ "${5:-}" = "--force" ]; then + FORCE=1 +fi SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" source "$SCRIPT_DIR/lib/storage.sh" @@ -14,6 +18,48 @@ DB="$(agmsg_db_path)" [ -f "$DB" ] || bash "$SCRIPT_DIR/internal/init-db.sh" >/dev/null +# #355: reject a from/to that isn't registered in <team> — an unnoticed typo +# (e.g. a stray send to "dummy") used to insert successfully with exit 0, +# landing an undeliverable message and polluting history. Validation lives +# here (the front door), not in storage.sh, so other entry points (api.sh) +# can keep their own policy. --force bypasses this for intentional +# pre-registration sends (e.g. notifying a role before its own join.sh runs). +if [ "$FORCE" -ne 1 ]; then + TEAM_CONFIG="$SCRIPT_DIR/../teams/$TEAM/config.json" + + _agmsg_roster_check() { + local role="$1" name="$2" + if [ ! -f "$TEAM_CONFIG" ]; then + echo "Error: team '$TEAM' has no registered agents — cannot send as $role '$name' (use --force to bypass)." >&2 + return 1 + fi + local cfg_sql name_sql found roster + cfg_sql=$(agmsg_sql_readfile_path "$TEAM_CONFIG") + name_sql=$(printf '%s' "$name" | sed "s/'/''/g") + found=$(agmsg_sqlite_mem " + WITH raw(json) AS (SELECT CAST(readfile('$cfg_sql') AS TEXT)), + cfg(json) AS (SELECT CASE WHEN json_valid(json) THEN json END FROM raw) + SELECT value + FROM cfg, json_each(json_extract(cfg.json, '\$.agents')) + WHERE key = '$name_sql'; + ") + if [ -z "$found" ]; then + roster=$(agmsg_sqlite_mem " + WITH raw(json) AS (SELECT CAST(readfile('$cfg_sql') AS TEXT)), + cfg(json) AS (SELECT CASE WHEN json_valid(json) THEN json END FROM raw) + SELECT group_concat(key, ', ') + FROM cfg, json_each(json_extract(cfg.json, '\$.agents')); + ") + echo "Error: $role agent '$name' is not registered in team '$TEAM' (registered: ${roster:-none}). Use --force to bypass." >&2 + return 1 + fi + return 0 + } + + _agmsg_roster_check "from" "$FROM" || exit 1 + _agmsg_roster_check "to" "$TO" || exit 1 +fi + # Escape EVERY interpolated value as a SQL string literal, not just body: a # team/agent name containing a single quote would otherwise break the INSERT # (correctness) or change its meaning (injection surface). diff --git a/scripts/session-start.sh b/scripts/session-start.sh index 5ccd60b5..17f78f13 100755 --- a/scripts/session-start.sh +++ b/scripts/session-start.sh @@ -48,6 +48,8 @@ source "$SCRIPT_DIR/lib/type-registry.sh" source "$SCRIPT_DIR/lib/manifest.sh" # shellcheck disable=SC1091 source "$SCRIPT_DIR/lib/gc.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/role-session.sh" # role->session reverse lookup (#339) # Identity sanity check — no point launching a watcher with an empty pair set. PAIRS=$("$SCRIPT_DIR/identities.sh" "$PROJECT" "$TYPE" 2>/dev/null || true) @@ -249,11 +251,58 @@ EOF fi fi +# --- Role-aware resume (#339). --- +# If this session's bare sid was recorded as a role's seat (by actas-claim, or +# codex actas), and that (team, agent) is registered for THIS project, emit the +# ROLE-FILTERED directive instead of the generic unfiltered one: watch.sh with a +# 4th <agent> arg restricts receive to that role AND re-claims its exclusivity +# lock. This covers a manual `claude --resume <uuid>` that bypasses spawn's actas +# boot prompt -- the resumed session re-arms as its role automatically. Fail-open: +# no record, no project match, or an unreadable record => generic directive. +ROLE_NAME=""; ROLE_TEAM="" +_bare_sid="$(agmsg_instance_bare_sid "$SESSION_ID" 2>/dev/null || printf '%s' "$SESSION_ID")" +_rec="$(agmsg_role_session_lookup_by_sid "$_bare_sid" 2>/dev/null || true)" +if [ -n "$_rec" ]; then + _r_agent="$(printf '%s\n' "$_rec" | sed -n 's/^agent=//p' | head -1)" + _r_team="$(printf '%s\n' "$_rec" | sed -n 's/^team=//p' | head -1)" + # Guard against a cross-project sid collision: only honor the record when its + # (team, agent) is actually one of this project's registered pairs. + if [ -n "$_r_agent" ] && [ -n "$_r_team" ] \ + && printf '%s\n' "$PAIRS" | grep -Fxq "$(printf '%s\t%s' "$_r_team" "$_r_agent")"; then + ROLE_NAME="$_r_agent"; ROLE_TEAM="$_r_team" + fi +fi + WATCH="$SKILL_DIR/scripts/watch.sh" # Shell-quote each argv so the host can paste the command into Monitor and run # it verbatim. A plain '...' wrap breaks on paths with an apostrophe # (/Users/o'brien/...); printf %q escapes spaces, quotes and other metacharacters -# safely for shell re-execution (#188). +# safely for shell re-execution (#188). A resumed role adds the 4th <agent> arg. +if [ -n "$ROLE_NAME" ]; then + WATCH_COMMAND="$(printf '%q %q %q %q %q' "$WATCH" "$INSTANCE_ID" "$PROJECT" "$TYPE" "$ROLE_NAME")" + cat <<EOF +AGMSG monitor mode (resumed role \`$ROLE_NAME\` in team \`$ROLE_TEAM\`): this +session was recorded as that role's seat, so invoke the Monitor tool now with the +following parameters, before any other action in this session. + + command: $WATCH_COMMAND + description: agmsg inbox stream (acting as $ROLE_NAME) + persistent: true + +The 4th argument restricts receive to \`$ROLE_NAME\` only and re-claims its actas +exclusivity lock — a resume restores context but not runtime state, so this +re-establishes the role. Send with \`send.sh ... $ROLE_NAME ...\` (this session's +active FROM is \`$ROLE_NAME\`). Each output line is one message: +\`<ts> | <team> | <from> → <to> | <body>\`. React as they arrive. + +Note: On a /clear or --continue/--resume re-fire, you may shortly see a +"Monitor … stopped" notification for an earlier 'agmsg inbox stream' +task. That is the previous watcher being cleaned up — expected. Do NOT +relaunch it; the Monitor you invoke from this directive replaces it. +EOF + exit 0 +fi + WATCH_COMMAND="$(printf '%q %q %q %q' "$WATCH" "$INSTANCE_ID" "$PROJECT" "$TYPE")" cat <<EOF diff --git a/scripts/spawn.sh b/scripts/spawn.sh index 1e0bbf98..c96207ad 100755 --- a/scripts/spawn.sh +++ b/scripts/spawn.sh @@ -49,6 +49,11 @@ set -euo pipefail # through to the CLI unchecked (the CLI rejects unknown # ids); the flag spelling comes from the type's manifest # `model_arg=`. Refused for a type with no model_arg. +# --fresh force a brand-new session even when the role has a +# resumable prior session. Without it, a type that supports +# resume (manifest `resume_arg=`) is brought back into its +# last session's context when that transcript still exists +# (#339); with it, spawn always boots fresh. # # Spawn options: extra CLI args to always pass a given type's launched # binary (e.g. a default permission mode or sandbox policy), configured @@ -83,6 +88,10 @@ source "$SCRIPT_DIR/lib/spawn-options.sh" source "$SCRIPT_DIR/lib/resolve-project.sh" # shellcheck disable=SC1091 source "$SCRIPT_DIR/lib/manifest.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/role-session.sh" # role->session record lookup (#339) +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/boot-command.sh" # shared boot-command construction (#339) die() { echo "spawn: $*" >&2; exit 1; } @@ -133,6 +142,7 @@ TERMINAL_TMPL="" # --terminal override (resolved below if empty) WAIT_READY=1 # block until the spawned agent's watcher attaches READY_TIMEOUT=90 # seconds to wait for readiness before giving up MODEL_ID="" # --model: pass-through model id for the launched CLI +FRESH=0 # --fresh: force a fresh session even if the role is resumable while [ $# -gt 0 ]; do case "$1" in @@ -149,6 +159,7 @@ while [ $# -gt 0 ]; do --no-wait) WAIT_READY=0; shift ;; --ready-timeout) READY_TIMEOUT="${2:?--ready-timeout needs seconds}"; shift 2 ;; --model) MODEL_ID="${2:?--model needs a model id}"; shift 2 ;; + --fresh) FRESH=1; shift ;; *) die "unknown option: $1" ;; esac done @@ -213,13 +224,24 @@ if [ -n "$MODEL_ID" ] && [ -z "$MODEL_ARG" ]; then die "agent type '$AGENT_TYPE' does not support --model (no model_arg in its manifest)" fi -# Some CLIs don't accept the actas prompt as a bare positional argument — they -# require it as the value of a named flag instead (e.g. antigravity's -# `--prompt-interactive <text>`, copilot's `-i/--interactive <text>`; their -# `-p/--prompt` equivalents are a DIFFERENT one-shot, non-interactive mode and -# would not work here). `prompt_arg=` in the manifest names that flag; unset -# (the default) keeps today's bare-positional behavior. -PROMPT_ARG="$(agmsg_type_get "$AGENT_TYPE" prompt_arg)" +# Note: prompt_arg= (some CLIs require the actas prompt as a named flag's value +# rather than a bare positional, e.g. antigravity's --prompt-interactive) is +# resolved inside agmsg_role_cli_args (lib/boot-command.sh) now, so it stays in +# sync with the name/resume flags across spawn and resurrect-panes.sh. + +# Session display name (#339). A type whose manifest declares `name_arg=` (e.g. +# claude-code's -n) is launched with `<name_arg> <team>-<agent>`, so the spawned +# session is born named after its role: meaningful in the prompt box / resume +# picker, and -- key for the tmux-resurrect hook -- recorded verbatim in the +# argv resurrect saves. Types without the key skip naming (unchanged). The name +# joins team and agent with a '-'; either half may itself contain '-', so the +# role-session record stores the whole `name=` for reverse lookup rather than +# splitting it apart. +# SESSION_NAME (<team>-<agent>) and the resume-or-fresh decision (#339) are both +# computed AFTER team resolution below (a project-resolved --team is only known +# then). The role-identity CLI args (name_arg/resume_arg/prompt) are emitted by +# agmsg_role_cli_args (lib/boot-command.sh), so the launch flag order stays in +# sync with resurrect-panes.sh. # Session-identity env vars to strip from a spawned same-type child (issue #294). # A terminal launcher (tmux new-window/split-window, a new OS terminal) copies @@ -310,6 +332,16 @@ if [ -z "$TEAM" ]; then fi fi +# Role's session display name (#339): now that TEAM is final, join it to the +# agent name. Emitted into the boot script when the type declares name_arg. +SESSION_NAME="${TEAM}-${NAME}" + +# Resume-or-fresh decision (#339): resumable session id, or empty for a fresh +# boot. All fail-open gates (force --fresh, no resume_arg, no record, stale/ +# missing transcript) live in agmsg_role_resume_uuid (lib/boot-command.sh), so +# spawn and resurrect-panes.sh decide identically. +RESUME_UUID="$(agmsg_role_resume_uuid "$AGENT_TYPE" "$TEAM" "$NAME" "$PROJECT" "$FRESH")" + # --- Pre-flight: refuse if <name> is currently held by another live session --- # The child's actas flow would refuse anyway; failing here avoids launching a # process that immediately can't take its identity. @@ -348,21 +380,38 @@ AGMSG_RESOLVE_PROJECT=0 "$SCRIPT_DIR/join.sh" "$TEAM" "$NAME" "$AGENT_TYPE" "$PR # its identity AND acts on the task in the same first turn. This is the only way # to hand a one-shot goal to a codex peer, which has no Monitor and so never # notices a message sent after it goes idle (see docs/codex-monitor-beta.md). -CMD_NAME="$(basename "$SKILL_DIR")" -# The skill-invocation prefix differs by CLI: Claude Code dispatches a "/" slash -# command, while agentskills-based CLIs (codex, gemini, antigravity) invoke a -# skill with "$" — a `codex '/agmsg actas ...'` boot prompt is not a reliable -# skill invocation (#283). type.conf's cmd_prefix= names it per type; unset -# defaults to "/" (Claude Code, the historical hardcoded value) so any type not -# explicitly configured keeps today's behavior. -CMD_PREFIX="$(agmsg_type_get "$AGENT_TYPE" cmd_prefix)" -[ -n "$CMD_PREFIX" ] || CMD_PREFIX="/" -ACTAS_PROMPT="${CMD_PREFIX}${CMD_NAME} actas ${NAME}" +# Base actas prompt: `<cmd_prefix><cmd_name> actas <name>` (the cmd_prefix "/" +# vs "$" per-CLI subtlety and the custom-install command name live in +# agmsg_actas_prompt, lib/boot-command.sh, shared with resurrect-panes.sh). When +# --boot-prompt gives a task, append it newline-separated so the agent claims its +# identity AND acts on the task in the same first turn -- the only way to hand a +# one-shot goal to a codex peer, which has no Monitor. +ACTAS_PROMPT="$(agmsg_actas_prompt "$AGENT_TYPE" "$NAME")" if [ -n "$PROMPT" ]; then ACTAS_PROMPT="${ACTAS_PROMPT} ${PROMPT}" fi +# Git Bash / MSYS path conversion rewrites exec args that look like absolute +# POSIX paths when invoking a native Windows binary: a '/<cmd> actas <name>' +# initial prompt reaches the CLI as 'C:/Program Files/Git/<cmd> actas <name>' +# and the agent never sees a valid skill invocation. Exclude args starting +# with the slash command from conversion. The exclusion is prefix-scoped on +# purpose — MSYS_NO_PATHCONV=1 would also stop converting genuine POSIX-path +# args (e.g. a node launcher's --project /e/...) that native CLIs rely on. +# Only the '/' prefix is path-shaped; '$'-prefixed prompts (#283) are never +# converted, and the variable is inert outside MSYS environments. +# cmd_prefix/cmd_name are resolved exactly as agmsg_actas_prompt does +# (lib/boot-command.sh) -- #344 moved that resolution into the helper, so the two +# inputs the guard needs are recomputed here rather than read from now-absent vars. +_msys_cmd_name="$(basename "$SKILL_DIR")" +_msys_cmd_prefix="$(agmsg_type_get "$AGENT_TYPE" cmd_prefix)" +[ -n "$_msys_cmd_prefix" ] || _msys_cmd_prefix="/" +MSYS_GUARD="" +if [ "$_msys_cmd_prefix" = "/" ]; then + MSYS_GUARD="MSYS2_ARG_CONV_EXCL=/${_msys_cmd_name} " +fi + BOOT_DIR="${TMPDIR:-/tmp}/agmsg-spawn" mkdir -p "$BOOT_DIR" 2>/dev/null || true # Best-effort GC of boot scripts left behind by spawns whose window was closed @@ -381,6 +430,10 @@ esac { echo '#!/usr/bin/env bash' printf 'cd %q || exit 1\n' "$PROJECT" + # Mark the launched session as spawn-born (#339): the CLI inherits this, so the + # actas flow knows the session is already named <team>-<agent> (name_arg) and + # suppresses the "rename this session" tip meant for hand-started sessions. + echo 'export AGMSG_SPAWNED=1' # Drop inherited same-type session-identity vars before exec'ing the CLI (#294). if [ -n "$SPAWN_UNSET_VARS" ]; then printf 'unset %s\n' "$SPAWN_UNSET_VARS" @@ -390,7 +443,7 @@ esac # Type-specific config is the launcher's own default/env, so core stays # generic and names no add-on. Spawn-options tokens (if any) land before # --initial-input, same relative position as the direct-CLI path below. - printf '%q %q \\\n' "$NODE_BIN" "$SPAWN_AGENT" + printf '%s%q %q \\\n' "$MSYS_GUARD" "$NODE_BIN" "$SPAWN_AGENT" printf ' --name %q \\\n' "$NAME" printf ' --team %q \\\n' "$TEAM" printf ' --project %q \\\n' "$PROJECT" @@ -400,20 +453,30 @@ esac printf ' --initial-input %q\n' "$ACTAS_PROMPT" else # Direct-CLI launch: - # `<cli> [<model_arg> <model_id>] [spawn-options...] [<prompt_arg>] "/<cmd> actas <name>"`. + # `<cli> [<resume_arg> <uuid>] [<model_arg> <model_id>] [spawn-options...] [<name_arg> <name>] [<prompt_arg>] "/<cmd> actas <name>"`. # cli is emitted unquoted — it is trusted fixed-prefix manifest data (see # above) that may itself be several tokens (e.g. `opencode run --interactive`). - # model_arg/prompt_arg are the manifest flag spellings (not %q-quoted — bare - # flags like --model or -i); the model id, every spawn-options token, and the - # actas prompt are quoted. prompt_arg (when set) lands immediately before the - # prompt so there is no ambiguity about which token is its value. - printf '%s' "$CLI_BIN" + # The resume head (#339) is emitted RIGHT AFTER the cli, before all other + # args: mandatory for a subcommand-shaped resume (codex `resume <id>`), + # harmless for a flag-shaped one (claude `--resume <id>`) -- see + # agmsg_role_resume_head. model_arg is the manifest flag spelling (bare, not + # %q-quoted); the model id and every spawn-options token are quoted. The + # role-identity tail (name/prompt_arg + the actas prompt) is emitted by + # agmsg_role_cli_args so its flag order matches resurrect-panes.sh. + # MSYS_GUARD (#336) prefixes the CLI line as a command-local env assignment; + # emitted with %s (not %q) so it stays an assignment, not a single token. + printf '%s%s' "$MSYS_GUARD" "$CLI_BIN" + agmsg_role_resume_head "$AGENT_TYPE" "$RESUME_UUID" [ -n "$MODEL_ID" ] && printf ' %s %q' "$MODEL_ARG" "$MODEL_ID" for _tok in ${SPAWN_OPT_TOKENS[@]+"${SPAWN_OPT_TOKENS[@]}"}; do printf ' %q' "$_tok" done - [ -n "$PROMPT_ARG" ] && printf ' %s' "$PROMPT_ARG" - printf ' %q\n' "$ACTAS_PROMPT" + # Role-identity tail: name the session and pass the actas prompt. The actas + # prompt runs in BOTH fresh and resume cases -- resume restores context only, + # so the actas re-run re-establishes the watcher, the lock, and the active + # FROM (claim is idempotent per sid). + agmsg_role_cli_args "$AGENT_TYPE" "$SESSION_NAME" "$ACTAS_PROMPT" + printf '\n' fi echo 'rm -f "$0" 2>/dev/null' # self-clean once the agent exits echo 'exec "${SHELL:-/bin/bash}" -i' @@ -435,17 +498,25 @@ launch_in_tmux() { command -v tmux >/dev/null 2>&1 \ || die "\$TMUX is set but the tmux binary is not on PATH; add it to PATH, or run outside tmux to use the OS-terminal path" + # On Windows (psmux), tmux launches processes via Windows APIs that do not + # process shebang lines; an extensionless boot script is accepted but never + # executed (#335). Wrap with `bash -l` — same pattern as launch_windows_terminal. + local -a tmux_boot=("$BOOT") + case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*) tmux_boot=(bash -l "$BOOT") ;; + esac + # Name the window/pane after the agent rather than letting tmux fall back to # the boot script's filename (boot-XXXXXX). `automatic-rename off` keeps the # name from being clobbered once the boot script runs the CLI / drops to a # shell. local target_id if [ "$TMUX_TARGET" = "window" ]; then - target_id="$(tmux new-window -P -F '#{window_id}' -n "$NAME" -c "$PROJECT" "$BOOT")" + target_id="$(tmux new-window -P -F '#{window_id}' -n "$NAME" -c "$PROJECT" "${tmux_boot[@]}")" tmux set-window-option -t "$target_id" automatic-rename off 2>/dev/null || true else local dir="-h"; [ "$SPLIT" = "v" ] && dir="-v" - target_id="$(tmux split-window "$dir" -P -F '#{pane_id}' -c "$PROJECT" "$BOOT")" + target_id="$(tmux split-window "$dir" -P -F '#{pane_id}' -c "$PROJECT" "${tmux_boot[@]}")" tmux select-pane -t "$target_id" -T "$NAME" 2>/dev/null || true fi # Record placement so `despawn --force` can tear this member down even if its diff --git a/tests/test_app_release_signing.bats b/tests/test_app_release_signing.bats new file mode 100644 index 00000000..7eb277d6 --- /dev/null +++ b/tests/test_app_release_signing.bats @@ -0,0 +1,49 @@ +#!/usr/bin/env bats + +# Regression guards for issue #333: on Windows, Authenticode signing must +# happen DURING `tauri build` (bundle > windows > signCommand), never as a +# post-build in-place pass — signing after the build mutates bytes the +# updater's minisign .sig was already computed over (every 0.1.4 in-app +# update failed verification) and corrupted the MSI's embedded cabinet. +# Real signing is only exercisable on main (OIDC federated identity), so +# these tests pin the workflow's structure instead. + +WORKFLOW="${BATS_TEST_DIRNAME}/../.github/workflows/app-release.yml" +SIGN_SCRIPT="${BATS_TEST_DIRNAME}/../app/scripts/sign-windows.ps1" + +@test "app-release: no post-build in-place signing action remains" { + # `run` + explicit status check, not bare `! grep ...`: bash's `set -e` + # (which bats enables for the test body) exempts `!`-negated commands from + # aborting the function on failure, so two bare `! grep` statements in a + # row would silently swallow the first one's failure — only the LAST + # statement's exit code would determine the test's outcome. + run grep -q -- "uses: azure/artifact-signing-action" "$WORKFLOW" + [ "$status" -ne 0 ] + run grep -q -- "uses: azure/trusted-signing-action" "$WORKFLOW" + [ "$status" -ne 0 ] +} + +@test "app-release: azure login runs before the windows build" { + login_line="$(grep -n "azure/login" "$WORKFLOW" | head -1 | cut -d: -f1)" + build_line="$(grep -n "pnpm tauri build --config" "$WORKFLOW" | head -1 | cut -d: -f1)" + [ -n "$login_line" ] + [ -n "$build_line" ] + [ "$login_line" -lt "$build_line" ] +} + +@test "app-release: windows build wires signCommand to sign-windows.ps1 per artifact" { + grep -q "signCommand" "$WORKFLOW" + grep -q "sign-windows.ps1" "$WORKFLOW" + grep -q '$sign_script %1' "$WORKFLOW" +} + +@test "app-release: shipped artifacts are verified as Authenticode-signed" { + grep -q "Get-AuthenticodeSignature" "$WORKFLOW" +} + +@test "sign-windows.ps1: same trusted-signing account/profile the action used" { + grep -q "Invoke-TrustedSigning" "$SIGN_SCRIPT" + grep -q "https://eus.codesigning.azure.net/" "$SIGN_SCRIPT" + grep -q "agmsg-artifact-signing" "$SIGN_SCRIPT" + grep -q "agmsg-app-signing" "$SIGN_SCRIPT" +} diff --git a/tests/test_bin_agmsg.bats b/tests/test_bin_agmsg.bats new file mode 100644 index 00000000..de40d9bd --- /dev/null +++ b/tests/test_bin_agmsg.bats @@ -0,0 +1,25 @@ +#!/usr/bin/env bats + +BIN="$BATS_TEST_DIRNAME/../bin/agmsg.js" + +@test "bin/agmsg.js: --version exits successfully" { + run node "$BIN" --version + [ "$status" -eq 0 ] + [[ "$output" =~ "agmsg bootstrapper" ]] +} + +@test "bin/agmsg.js: --help exits successfully" { + run node "$BIN" --help + [ "$status" -eq 0 ] + [[ "$output" =~ "npm bootstrapper for cross-agent messaging" ]] +} + +@test "bin/agmsg.js: toBashPath converts backslashes to forward slashes (#262)" { + run node -e 'const { toBashPath } = require(process.argv[1]); const input = String.raw`C:\Users\me\AppData\Local\Temp\agmsg-bootstrap-abc123\setup.sh`; const expected = "C:/Users/me/AppData/Local/Temp/agmsg-bootstrap-abc123/setup.sh"; if (toBashPath(input) !== expected) process.exit(1);' "$BIN" + [ "$status" -eq 0 ] +} + +@test "bin/agmsg.js: toBashPath is a no-op on POSIX paths" { + run node -e 'const { toBashPath } = require(process.argv[1]); const p = "/tmp/agmsg-bootstrap-abc123/setup.sh"; if (toBashPath(p) !== p) process.exit(1);' "$BIN" + [ "$status" -eq 0 ] +} diff --git a/tests/test_boot_command.bats b/tests/test_boot_command.bats new file mode 100644 index 00000000..125bfd75 --- /dev/null +++ b/tests/test_boot_command.bats @@ -0,0 +1,95 @@ +#!/usr/bin/env bats + +# Unit tests for the shared boot-command construction (#339): +# scripts/lib/boot-command.sh +# Covers the one-key, cli-immediately-after resume convention and the actas +# prompt / role-args tail, independent of spawn.sh and resurrect-panes.sh. + +load test_helper + +setup() { + setup_test_env + export SKILL_DIR="$TEST_SKILL_DIR" + # shellcheck disable=SC1090 + source "$SKILL_DIR/scripts/lib/type-registry.sh" + # shellcheck disable=SC1090 + source "$SKILL_DIR/scripts/lib/role-session.sh" + # shellcheck disable=SC1090 + source "$SKILL_DIR/scripts/lib/boot-command.sh" +} + +teardown() { teardown_test_env; } + +# --- agmsg_role_resume_head --- + +@test "resume_head: empty uuid emits nothing (fresh)" { + [ -z "$(agmsg_role_resume_head claude-code "")" ] +} + +@test "resume_head: emits the manifest resume_arg value verbatim, then the uuid" { + # claude-code's manifest resume_arg is --resume. + [ "$(agmsg_role_resume_head claude-code sess-1)" = " --resume sess-1" ] +} + +@test "resume_head: nothing when the type has no resume_arg" { + [ -z "$(agmsg_role_resume_head gemini sess-1)" ] +} + +@test "resume_head: composes right after the cli, before other args" { + # This is the whole convention: <cli><resume head>... must yield + # `claude --resume <uuid>` adjacently, so a subcommand shape would too. + local line + line="claude$(agmsg_role_resume_head claude-code sess-1)$(agmsg_role_cli_args claude-code T-alice '/agmsg actas alice')" + [[ "$line" == "claude --resume sess-1 "* ]] +} + +# --- agmsg_role_cli_args (tail) --- + +@test "role_cli_args: name flag + prompt, no resume token in the tail" { + local out; out="$(agmsg_role_cli_args claude-code T-alice '/agmsg actas alice')" + [[ "$out" == *"-n T-alice"* ]] + [[ "$out" == *"actas"* ]] + [[ "$out" != *"--resume"* ]] +} + +@test "role_cli_args: a type without name_arg omits the name flag" { + local out; out="$(agmsg_role_cli_args gemini T-alice '/agmsg actas alice')" + [[ "$out" != *" -n "* ]] + [[ "$out" == *"actas"* ]] +} + +@test "role_cli_args: %q-quotes the prompt so spaces survive" { + local out; out="$(agmsg_role_cli_args claude-code T-alice '/agmsg actas alice')" + # printf %q renders the spaces as backslash-escapes. + [[ "$out" == *'/agmsg\ actas\ alice'* ]] +} + +# --- agmsg_actas_prompt --- + +@test "actas_prompt: default '/' prefix + install command name + actas <agent>" { + # cmd name is the skill dir basename (the install command name). + local cmd; cmd="$(basename "$SKILL_DIR")" + [ "$(agmsg_actas_prompt claude-code alice)" = "/${cmd} actas alice" ] +} + +# --- agmsg_role_resume_uuid (gate) --- + +@test "role_resume_uuid: empty for a type with no resume_arg" { + agmsg_role_session_record T alice sess-1 /proj gemini + [ -z "$(agmsg_role_resume_uuid gemini T alice /proj)" ] +} + +@test "role_resume_uuid: empty when force_fresh is set" { + agmsg_role_session_record T alice sess-1 /proj claude-code + # transcript present, but force_fresh=1 must still yield empty. + local munged; munged="$(printf '%s' /proj | LC_ALL=C sed 's/[^A-Za-z0-9-]/-/g')" + mkdir -p "$HOME/.claude/projects/$munged"; : > "$HOME/.claude/projects/$munged/sess-1.jsonl" + [ -z "$(agmsg_role_resume_uuid claude-code T alice /proj 1)" ] +} + +@test "role_resume_uuid: returns the uuid when record + transcript exist" { + agmsg_role_session_record T alice sess-1 /proj claude-code + local munged; munged="$(printf '%s' /proj | LC_ALL=C sed 's/[^A-Za-z0-9-]/-/g')" + mkdir -p "$HOME/.claude/projects/$munged"; : > "$HOME/.claude/projects/$munged/sess-1.jsonl" + [ "$(agmsg_role_resume_uuid claude-code T alice /proj)" = "sess-1" ] +} diff --git a/tests/test_codex_bridge_launcher.bats b/tests/test_codex_bridge_launcher.bats new file mode 100644 index 00000000..c155f1ab --- /dev/null +++ b/tests/test_codex_bridge_launcher.bats @@ -0,0 +1,74 @@ +#!/usr/bin/env bats + +# Unit tests for codex-bridge-launcher.sh thread resolution (#350). +# The launcher must bind the bridge to the role's RECORDED codex thread instead +# of the app-server's ambiguous "loaded" thread (which a co-resident codex thread +# in the same cwd could otherwise capture). A mock bridge (AGMSG_CODEX_BRIDGE_CMD) +# records the --thread the launcher passes. + +load test_helper + +setup() { + setup_test_env + export SKILL_DIR="$TEST_SKILL_DIR" + export RUN_DIR="$SKILL_DIR/run"; mkdir -p "$RUN_DIR" + export PROJ="$TEST_SKILL_DIR/proj"; mkdir -p "$PROJ" + bash "$SCRIPTS/join.sh" team alice codex "$PROJ" >/dev/null + + export CAPTURE="$TEST_SKILL_DIR/thread-capture.txt" + export MOCK="$TEST_SKILL_DIR/mock-bridge.sh" + cat > "$MOCK" <<EOF +#!/usr/bin/env bash +printf '%s\n' "\$*" >> "$CAPTURE" +exit 0 +EOF + chmod +x "$MOCK" + export AGMSG_CODEX_BRIDGE_CMD="$MOCK" + export LAUNCHER="$SCRIPTS/drivers/types/codex/codex-bridge-launcher.sh" +} + +teardown() { teardown_test_env; } + +# Write a role-session record (team, agent) -> thread for a project. +put_record() { + SKILL_DIR="$TEST_SKILL_DIR" bash -c \ + 'source "$1/lib/role-session.sh"; agmsg_role_session_record "$2" "$3" "$4" "$5" "$6"' \ + _ "$SCRIPTS" "$@" +} + +# Drive the launcher against a short-lived parent, blocking until it exits. fd 3 +# is closed on the backgrounded parent and the launcher so a stray descriptor +# can't keep bats from exiting on macOS (#bats-fd3). +run_launcher() { + sleep 2 3>&- & local p=$! + bash "$LAUNCHER" codex "$PROJ" "ws://127.0.0.1:1" "$p" >/dev/null 2>&1 3>&- || true + wait "$p" 2>/dev/null || true +} + +@test "launcher: binds the recorded thread when the record's project matches (#350)" { + put_record team alice rec-thread-1 "$PROJ" codex + run_launcher + [ -f "$CAPTURE" ] + grep -q -- "--thread rec-thread-1" "$CAPTURE" + ! grep -q -- "--thread loaded" "$CAPTURE" +} + +@test "launcher: falls back to 'loaded' when no record exists (#350)" { + run_launcher + [ -f "$CAPTURE" ] + grep -q -- "--thread loaded" "$CAPTURE" +} + +@test "launcher: falls back to 'loaded' when the record is for a different project (#350)" { + put_record team alice other-thread "/some/other/project" codex + run_launcher + [ -f "$CAPTURE" ] + grep -q -- "--thread loaded" "$CAPTURE" + ! grep -q -- "--thread other-thread" "$CAPTURE" +} + +@test "launcher: writes the bound-thread file so a later launcher can rebind (#350)" { + put_record team alice rec-thread-1 "$PROJ" codex + run_launcher + [ "$(cat "$RUN_DIR/codex-bridge.team.alice.thread" 2>/dev/null)" = "rec-thread-1" ] +} diff --git a/tests/test_codex_resume.bats b/tests/test_codex_resume.bats new file mode 100644 index 00000000..da66c8f3 --- /dev/null +++ b/tests/test_codex_resume.bats @@ -0,0 +1,107 @@ +#!/usr/bin/env bats + +# Unit tests for codex session resume wiring (#339): +# scripts/drivers/types/codex/_transcript-exists.sh +# scripts/drivers/types/codex/codex-record-session.sh +# Codex resumes via `codex resume <SESSION_ID>` (subcommand) and, unlike +# claude-code, records its role->session at actas time (it never runs actas-claim). + +load test_helper + +setup() { + setup_test_env + export SKILL_DIR="$TEST_SKILL_DIR" + export RUN_DIR="$SKILL_DIR/run" + mkdir -p "$RUN_DIR" + export CODEX_SESSIONS="$HOME/.codex/sessions" +} + +teardown() { teardown_test_env; } + +# Write a codex rollout file with a session_meta first line carrying id + cwd. +make_rollout() { + local uuid="$1" cwd="$2" day="${3:-2026/07/05}" ts="${4:-2026-07-05T10-00-00}" + local dir="$CODEX_SESSIONS/$day" + mkdir -p "$dir" + printf '{"type":"session_meta","payload":{"id":"%s","cwd":"%s"}}\n' "$uuid" "$cwd" \ + > "$dir/rollout-$ts-$uuid.jsonl" +} + +# --- _transcript-exists.sh --- + +@test "codex transcript_exists: true when a rollout with the uuid exists" { + # shellcheck disable=SC1090 + source "$TYPES/codex/_transcript-exists.sh" + make_rollout "abc-uuid" "/proj" + agmsg_transcript_exists "abc-uuid" "/proj" +} + +@test "codex transcript_exists: false when no rollout carries the uuid" { + # shellcheck disable=SC1090 + source "$TYPES/codex/_transcript-exists.sh" + make_rollout "other-uuid" "/proj" + ! agmsg_transcript_exists "abc-uuid" "/proj" +} + +@test "codex transcript_exists: finds the rollout regardless of the date dir" { + # shellcheck disable=SC1090 + source "$TYPES/codex/_transcript-exists.sh" + make_rollout "deep-uuid" "/proj" "2026/06/01" "2026-06-01T09-09-09" + agmsg_transcript_exists "deep-uuid" "/anything" # project is not part of the lookup +} + +@test "codex transcript_exists: empty uuid / unset HOME are not found" { + # shellcheck disable=SC1090 + source "$TYPES/codex/_transcript-exists.sh" + make_rollout "abc-uuid" "/proj" + ! agmsg_transcript_exists "" "/proj" + HOME="" run agmsg_transcript_exists "abc-uuid" "/proj" + [ "$status" -ne 0 ] +} + +# --- codex-record-session.sh --- + +# Read back the recorded uuid for (team, agent). +recorded_uuid() { + # shellcheck disable=SC1090 + source "$SKILL_DIR/scripts/lib/role-session.sh" + agmsg_role_session_uuid "$1" "$2" +} + +@test "codex record: prefers CODEX_THREAD_ID (unambiguous env path)" { + local proj; proj="$(mktemp -d)" + CODEX_THREAD_ID="env-thread-1" \ + bash "$TYPES/codex/codex-record-session.sh" team alice "$proj" + [ "$(recorded_uuid team alice)" = "env-thread-1" ] + # type is recorded as codex. + source "$SKILL_DIR/scripts/lib/role-session.sh" + [ "$(agmsg_role_session_get team alice type)" = "codex" ] +} + +@test "codex record: falls back to the unique matching-cwd rollout when env is unset" { + local proj; proj="$(mktemp -d)" + make_rollout "fallback-uuid" "$proj" + ( unset CODEX_THREAD_ID; bash "$TYPES/codex/codex-record-session.sh" team alice "$proj" ) + [ "$(recorded_uuid team alice)" = "fallback-uuid" ] +} + +@test "codex record: records NOTHING when two recent rollouts share the cwd (ambiguous)" { + local proj; proj="$(mktemp -d)" + make_rollout "uuid-A" "$proj" "2026/07/05" "2026-07-05T10-00-00" + make_rollout "uuid-B" "$proj" "2026/07/05" "2026-07-05T11-00-00" + ( unset CODEX_THREAD_ID; bash "$TYPES/codex/codex-record-session.sh" team alice "$proj" ) + [ -z "$(recorded_uuid team alice)" ] +} + +@test "codex record: records nothing when no rollout matches the cwd" { + local proj; proj="$(mktemp -d)" + make_rollout "elsewhere-uuid" "/some/other/cwd" + ( unset CODEX_THREAD_ID; bash "$TYPES/codex/codex-record-session.sh" team alice "$proj" ) + [ -z "$(recorded_uuid team alice)" ] +} + +@test "codex record: missing args are a no-op" { + run bash "$TYPES/codex/codex-record-session.sh" team "" /proj + [ "$status" -eq 0 ] + [ -z "$(recorded_uuid team alice)" ] +} diff --git a/tests/test_codex_shim.bats b/tests/test_codex_shim.bats index ce1eb336..9bdfc006 100644 --- a/tests/test_codex_shim.bats +++ b/tests/test_codex_shim.bats @@ -53,6 +53,15 @@ teardown() { grep -q "monitor real=$FAKE_CODEX <--project> <$TEST_PROJECT> <--codex-command> <codex> <--> <fix this>" "$CALL_LOG" } +@test "codex shim: monitor project forwards a flags-only launch to codex-monitor (#386)" { + bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null + + run bash -c 'cd "$TEST_PROJECT" && AGMSG_REAL_CODEX="$FAKE_CODEX" AGMSG_CODEX_MONITOR_CMD="$FAKE_MONITOR" bash "$TYPES/codex/codex-shim.sh" --yolo' + + [ "$status" -eq 0 ] + grep -q "monitor real=$FAKE_CODEX <--project> <$TEST_PROJECT> <--codex-command> <codex> <--> <--yolo>" "$CALL_LOG" +} + @test "codex shim: non-monitor project passes through to real codex" { bash "$SCRIPTS/delivery.sh" set turn codex "$TEST_PROJECT" >/dev/null diff --git a/tests/test_delivery.bats b/tests/test_delivery.bats index a307e2a5..3c912d0c 100644 --- a/tests/test_delivery.bats +++ b/tests/test_delivery.bats @@ -240,6 +240,60 @@ settings_file() { [ "$3" = "$sp" ] } +# --- session-start.sh role-aware resume directive (#339) --- + +# Write a role-session record into the isolated skill dir's run/. +_seed_role_record() { + local team="$1" agent="$2" sid="$3" proj="$4" type="${5:-claude-code}" + SKILL_DIR="$TEST_SKILL_DIR" bash -c ' + source "$1/lib/role-session.sh" + agmsg_role_session_record "$2" "$3" "$4" "$5" "$6" + ' _ "$SCRIPTS" "$team" "$agent" "$sid" "$proj" "$type" +} + +@test "session-start: a resumed role's sid emits the role-filtered directive (#339)" { + local sp="$TEST_PROJECT" + env AGMSG_RESOLVE_PROJECT=0 bash "$SCRIPTS/join.sh" team alice claude-code "$sp" >/dev/null + _seed_role_record team alice "sid-resumed" "$sp" claude-code + + run env AGMSG_RESOLVE_PROJECT=0 bash "$SCRIPTS/session-start.sh" claude-code "$sp" <<< '{"session_id":"sid-resumed"}' + [ "$status" -eq 0 ] + [[ "$output" == *"resumed role"* ]] + [[ "$output" == *"acting as alice"* ]] + # The 4th watch.sh arg restricts receive to the role. + local cmdline; cmdline=$(printf '%s\n' "$output" | sed -n 's/^[[:space:]]*command: //p') + eval "set -- $cmdline" + [ "$5" = "alice" ] +} + +@test "session-start: an unrecorded sid emits the generic directive (#339)" { + local sp="$TEST_PROJECT" + env AGMSG_RESOLVE_PROJECT=0 bash "$SCRIPTS/join.sh" team alice claude-code "$sp" >/dev/null + # no record for this sid + + run env AGMSG_RESOLVE_PROJECT=0 bash "$SCRIPTS/session-start.sh" claude-code "$sp" <<< '{"session_id":"sid-unknown"}' + [ "$status" -eq 0 ] + [[ "$output" != *"resumed role"* ]] + [[ "$output" == *"invoke the Monitor tool"* ]] + # Generic directive: watch.sh has no 4th (role) arg. + local cmdline; cmdline=$(printf '%s\n' "$output" | sed -n 's/^[[:space:]]*command: //p') + eval "set -- $cmdline" + [ "$#" -eq 4 ] +} + +@test "session-start: a record for a role not registered here is ignored (#339)" { + local sp="$TEST_PROJECT" + env AGMSG_RESOLVE_PROJECT=0 bash "$SCRIPTS/join.sh" team alice claude-code "$sp" >/dev/null + # Same sid, but recorded for a (team, agent) that is NOT registered in this + # project -- a cross-project sid collision must not mis-seat this session. + _seed_role_record team ghost "sid-resumed" "/some/other/proj" claude-code + + run env AGMSG_RESOLVE_PROJECT=0 bash "$SCRIPTS/session-start.sh" claude-code "$sp" <<< '{"session_id":"sid-resumed"}' + [ "$status" -eq 0 ] + [[ "$output" != *"resumed role"* ]] + [[ "$output" == *"invoke the Monitor tool"* ]] +} + @test "delivery set turn: emits AGMSG-DIRECTIVE to stop any running watcher" { run bash "$SCRIPTS/delivery.sh" set turn claude-code "$TEST_PROJECT" [[ "$output" =~ "AGMSG-DIRECTIVE" ]] @@ -1392,6 +1446,31 @@ JSON [ "$count" -eq 1 ] } +@test "antigravity supports off mode: removes rule file" { + bash "$SCRIPTS/delivery.sh" set turn antigravity "$TEST_PROJECT" + [ -f "$TEST_PROJECT/.agent/rules/agmsg.md" ] + run bash "$SCRIPTS/delivery.sh" set off antigravity "$TEST_PROJECT" + [ "$status" -eq 0 ] + [ ! -f "$TEST_PROJECT/.agent/rules/agmsg.md" ] +} + +# #399: type.conf previously advertised delivery_modes=monitor turn both off, +# but antigravity has no Monitor tool or bridge equivalent — the manifest must +# match what the template actually offers (turn/off only, like cursor/gemini). +@test "antigravity rejects monitor mode" { + run bash "$SCRIPTS/delivery.sh" set monitor antigravity "$TEST_PROJECT" + [ "$status" -ne 0 ] + [[ "$output" =~ "not supported" ]] + [ ! -f "$TEST_PROJECT/.agent/rules/agmsg.md" ] +} + +@test "antigravity rejects both mode" { + run bash "$SCRIPTS/delivery.sh" set both antigravity "$TEST_PROJECT" + [ "$status" -ne 0 ] + [[ "$output" =~ "not supported" ]] + [ ! -f "$TEST_PROJECT/.agent/rules/agmsg.md" ] +} + # --- Codex monitor bridge (#41) --- @test "session-start.sh for codex starts bridge when monitor launcher env is present" { bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null diff --git a/tests/test_despawn.bats b/tests/test_despawn.bats index 069faa43..a01f8a32 100644 --- a/tests/test_despawn.bats +++ b/tests/test_despawn.bats @@ -19,6 +19,7 @@ teardown() { @test "despawn: graceful — ctrl:despawn makes the member drop its role" { bash "$SCRIPTS/join.sh" team alice claude-code "$PROJ" >/dev/null + bash "$SCRIPTS/join.sh" team leader claude-code "$PROJ" >/dev/null # Make the member session look alive so the leader sees a live lock to wait on. setup_live_owner "$RUN" sess-m @@ -71,6 +72,7 @@ teardown() { @test "despawn: times out (exit 3) when the member never drops" { bash "$SCRIPTS/join.sh" team alice claude-code "$PROJ" >/dev/null + bash "$SCRIPTS/join.sh" team leader claude-code "$PROJ" >/dev/null setup_live_owner "$RUN" sess-m printf 'sess-m\n' > "$RUN/actas.team__alice.session" # held live, no watcher to act @@ -86,6 +88,7 @@ teardown() { # take down the leader session. A broad watcher must skip the control message. bash "$SCRIPTS/join.sh" team alice claude-code "$PROJ" >/dev/null bash "$SCRIPTS/join.sh" team leader claude-code "$PROJ" >/dev/null + bash "$SCRIPTS/join.sh" team boss claude-code "$PROJ" >/dev/null # Broad watcher (no actas arg) — subscribes to both alice and leader. AGMSG_WATCH_INTERVAL=1 env -u TMUX_PANE bash "$SCRIPTS/watch.sh" sess-broad "$PROJ" claude-code \ diff --git a/tests/test_install.bats b/tests/test_install.bats index 44fe58c3..e8c3481e 100644 --- a/tests/test_install.bats +++ b/tests/test_install.bats @@ -38,6 +38,8 @@ teardown() { @test "install: --update restores scripts/lib even if it went missing" { HOME="$FAKE_HOME" bash "$REPO_ROOT/install.sh" --cmd agmsg + bash "$SK/scripts/join.sh" demo alice claude-code /tmp/install-update-projA + bash "$SK/scripts/join.sh" demo bob claude-code /tmp/install-update-projB rm -rf "$SK/scripts/lib" HOME="$FAKE_HOME" bash "$REPO_ROOT/install.sh" --update [ -f "$SK/scripts/lib/storage.sh" ] @@ -109,6 +111,8 @@ teardown() { @test "install: AGMSG_STORAGE_PATH override works against the installed skill" { HOME="$FAKE_HOME" bash "$REPO_ROOT/install.sh" --cmd agmsg + bash "$SK/scripts/join.sh" demo alice claude-code /tmp/install-override-projA + bash "$SK/scripts/join.sh" demo bob claude-code /tmp/install-override-projB local store="$FAKE_HOME/override-store" AGMSG_STORAGE_PATH="$store" bash "$SK/scripts/send.sh" demo alice bob "via override" [ -f "$store/messages.db" ] diff --git a/tests/test_instance_id.bats b/tests/test_instance_id.bats index 3ab9bae0..c7ced908 100644 --- a/tests/test_instance_id.bats +++ b/tests/test_instance_id.bats @@ -63,6 +63,25 @@ teardown() { teardown_test_env; } ! agmsg_instance_is_composite "sess.12a" } +# --- agmsg_instance_bare_sid --- + +@test "bare_sid: strips the pid from a composite token" { + [ "$(agmsg_instance_bare_sid "sess.1234")" = "sess" ] +} + +@test "bare_sid: UUID-shaped composite yields the bare UUID" { + [ "$(agmsg_instance_bare_sid "11111111-2222-3333-4444-555555555555.987")" = "11111111-2222-3333-4444-555555555555" ] +} + +@test "bare_sid: a bare sid passes through unchanged" { + [ "$(agmsg_instance_bare_sid "sess")" = "sess" ] +} + +@test "bare_sid: a non-composite token with a dot but non-numeric suffix is unchanged" { + # "sess.12a" is NOT composite (suffix not all-digits), so it is a bare sid. + [ "$(agmsg_instance_bare_sid "sess.12a")" = "sess.12a" ] +} + # --- agmsg_instance_alive --- @test "instance_alive: composite with a live pid is alive" { diff --git a/tests/test_messaging.bats b/tests/test_messaging.bats index 6d5ccf85..f27e3885 100644 --- a/tests/test_messaging.bats +++ b/tests/test_messaging.bats @@ -26,6 +26,41 @@ teardown() { [ "$status" -ne 0 ] } +# --- send.sh: roster validation (#355) --- + +@test "send: rejects an unregistered from agent and does not insert" { + run bash "$SCRIPTS/send.sh" testteam dummy bob "hi" + [ "$status" -ne 0 ] + [[ "$output" =~ "from agent 'dummy' is not registered" ]] + local n + n=$(sqlite3 "$TEST_SKILL_DIR/db/messages.db" "SELECT COUNT(*) FROM messages;") + [ "$n" -eq 0 ] +} + +@test "send: rejects an unregistered to agent and does not insert" { + run bash "$SCRIPTS/send.sh" testteam alice dummy "hi" + [ "$status" -ne 0 ] + [[ "$output" =~ "to agent 'dummy' is not registered" ]] + local n + n=$(sqlite3 "$TEST_SKILL_DIR/db/messages.db" "SELECT COUNT(*) FROM messages;") + [ "$n" -eq 0 ] +} + +@test "send: rejection lists the currently registered roster" { + run bash "$SCRIPTS/send.sh" testteam alice dummy "hi" + [ "$status" -ne 0 ] + [[ "$output" =~ "registered: alice, bob" ]] +} + +@test "send: --force bypasses the roster check even with no team config at all" { + run bash "$SCRIPTS/send.sh" brandnewteam ghost nobody "hi" --force + [ "$status" -eq 0 ] + [[ "$output" =~ "Sent to nobody" ]] + local n + n=$(sqlite3 "$TEST_SKILL_DIR/db/messages.db" "SELECT COUNT(*) FROM messages WHERE team='brandnewteam';") + [ "$n" -eq 1 ] +} + # --- inbox.sh --- @test "inbox: shows no messages when empty" { @@ -91,6 +126,7 @@ line3" @test "check-inbox: a team name containing a quote still delivers without a SQL error (#87)" { local project; project="$(mktemp -d)" + bash "$SCRIPTS/join.sh" "te'am" alice claude-code /tmp/project-a bash "$SCRIPTS/join.sh" "te'am" carol claude-code "$project" bash "$SCRIPTS/send.sh" "te'am" alice carol "quoted team delivery" run bash -c "echo '{}' | bash '$SCRIPTS/check-inbox.sh' claude-code '$project'" diff --git a/tests/test_resolve_project.bats b/tests/test_resolve_project.bats index 67c270ec..807c053a 100644 --- a/tests/test_resolve_project.bats +++ b/tests/test_resolve_project.bats @@ -69,6 +69,102 @@ reg() { [ "$result" = "$ROOT/sub" ] # no codex registration → unchanged } +# --- #357: over-reach of the ancestor walk (poison registrations) --- + +# Inject a registration directly into a team's config, bypassing join.sh's guard +# -- this simulates a poison registration left by an older version (join now +# refuses $HOME / root, but old data persists). +poison_reg() { # <team> <agent> <project> [type] + local team="$1" agent="$2" proj="$3" type="${4:-claude-code}" + mkdir -p "$SKILL_DIR/teams/$team" + cat > "$SKILL_DIR/teams/$team/config.json" <<JSON +{"name":"$team","agents":{"$agent":{"registrations":[{"type":"$type","project":"$proj"}]}}} +JSON +} + +@test "resolve: a \$HOME registration never captures resolution (#357 shallow-exclusion)" { + local home_norm; home_norm="$(agmsg_normalize_project_path "$HOME")" + mkdir -p "$HOME/agmsg-agents/aglive" + poison_reg test cc "$home_norm" claude-code # poison: $HOME registered + + # Sanity: the poison IS in the (all-teams) registry, so this really exercises + # the exclusion rather than a missing registration. + agmsg_registered_projects claude-code | grep -Fxq -- "$home_norm" + + # A session deep under $HOME must NOT resolve up to $HOME. + result="$(agmsg_resolve_project "$HOME/agmsg-agents/aglive" claude-code)" + [ "$result" = "$(agmsg_normalize_project_path "$HOME/agmsg-agents/aglive")" ] +} + +@test "resolve: a / registration never captures resolution (#357)" { + poison_reg test cc "/" claude-code + other="$(mktemp -d)" + result="$(agmsg_resolve_project "$other/x" claude-code)" + [ "$result" = "$other/x" ] # falls back to pwd, not "/" + rm -rf "$other" +} + +@test "resolve: a \$HOME/ (trailing slash) registration is still excluded (#357 normalized compare)" { + local home_norm; home_norm="$(agmsg_normalize_project_path "$HOME")" + mkdir -p "$HOME/agmsg-agents/aglive" + poison_reg test cc "$home_norm/" claude-code # stored WITH a trailing slash + + # The walk generates a trailing-slash candidate too, so this really matches the + # poison -- the exclusion must still fire because it compares normalized paths. + result="$(agmsg_resolve_project "$HOME/agmsg-agents/aglive" claude-code)" + [ "$result" = "$(agmsg_normalize_project_path "$HOME/agmsg-agents/aglive")" ] +} + +@test "resolve: a // (doubled-slash root) registration is still excluded (#357)" { + poison_reg test cc "//" claude-code + other="$(mktemp -d)" + result="$(agmsg_resolve_project "$other/x" claude-code)" + [ "$result" = "$other/x" ] # // normalizes to / -> excluded + rm -rf "$other" +} + + +@test "resolve: team scoping ignores another team's registration (#357)" { + local home_norm; home_norm="$(agmsg_normalize_project_path "$HOME")" + mkdir -p "$HOME/agmsg-agents/aglive" + poison_reg test cc "$home_norm" claude-code # poison lives in team 'test' + + # Scoped to 'aglive' (no registration there) → the 'test' poison is invisible. + result="$(agmsg_resolve_project "$HOME/agmsg-agents/aglive" claude-code aglive)" + [ "$result" = "$(agmsg_normalize_project_path "$HOME/agmsg-agents/aglive")" ] +} + +@test "registered_projects: a team scope returns only that team's projects (#357)" { + reg aglive lead "$ROOT" claude-code + poison_reg other cc "/some/other/proj" claude-code + + run agmsg_registered_projects claude-code aglive + [[ "$output" == *"$ROOT"* ]] + [[ "$output" != *"/some/other/proj"* ]] + + # No team → legacy all-teams scan still sees both (back-compat). + run agmsg_registered_projects claude-code + [[ "$output" == *"$ROOT"* ]] + [[ "$output" == *"/some/other/proj"* ]] +} + +@test "join: ALLOWS registering a project at \$HOME (deliberate use case) (#357)" { + # Starting a project at $HOME is legitimate (both claude and codex run there); + # #357 protects on the resolution side, not by refusing the registration. + run env AGMSG_RESOLVE_PROJECT=0 bash "$SKILL_DIR/scripts/join.sh" T alice claude-code "$HOME" + [ "$status" -eq 0 ] +} + +@test "resolve: an exact \$HOME registration still resolves \$HOME for a session AT \$HOME (#357)" { + # The exclusion stops the ancestor walk from LANDING on $HOME for sessions + # beneath it, but a session whose pwd IS $HOME still resolves to $HOME -- via + # the pwd fallback, so "someone who started there works". + local home_norm; home_norm="$(agmsg_normalize_project_path "$HOME")" + poison_reg test cc "$home_norm" claude-code + result="$(agmsg_resolve_project "$HOME" claude-code)" + [ "$result" = "$home_norm" ] +} + # --- opt-out --- @test "resolve: AGMSG_RESOLVE_PROJECT=0 forces the raw pwd" { diff --git a/tests/test_resurrect_panes.bats b/tests/test_resurrect_panes.bats new file mode 100644 index 00000000..16fdc7f7 --- /dev/null +++ b/tests/test_resurrect_panes.bats @@ -0,0 +1,173 @@ +#!/usr/bin/env bats + +# Unit tests for the tmux-resurrect post-restore hook (#339 PR-D): +# scripts/internal/resurrect-panes.sh +# Exercise the pure parse + command-construction core (agmsg_resurrect_plan) +# against a fixture save file. The live send-keys / kill-server path is a manual +# checklist item in the PR, not CI. + +load test_helper + +setup() { + setup_test_env + export SKILL_DIR="$TEST_SKILL_DIR" + export RUN_DIR="$SKILL_DIR/run" + mkdir -p "$RUN_DIR" + # Source the hook for its functions (guarded: sourcing does not run main). + # shellcheck disable=SC1090 + source "$SCRIPTS/internal/resurrect-panes.sh" + export FIXTURE="$TEST_SKILL_DIR/resurrect.txt" +} + +teardown() { teardown_test_env; } + +# Write a role-session record straight to run/ (bypasses actas-claim). +put_record() { + local team="$1" agent="$2" uuid="$3" proj="$4" type="${5:-claude-code}" + agmsg_role_session_record "$team" "$agent" "$uuid" "$proj" "$type" +} + +# Append a tmux-resurrect pane line to the fixture. Tab-separated; matches the +# 11-field layout the parser expects. +pane_line() { + local session="$1" windex="$2" pindex="$3" title="$4" path="$5" cmd="$6" full="$7" + printf 'pane\t%s\t%s\t1\t:*\t%s\t%s\t:%s\t1\t%s\t:%s\n' \ + "$session" "$windex" "$pindex" "$title" "$path" "$cmd" "$full" >> "$FIXTURE" +} + +# Create the transcript Claude Code would have for (uuid, project) so the resume +# gate fires. Mirrors the driver munging. +make_transcript() { + local uuid="$1" project="$2" munged + munged="$(printf '%s' "$project" | LC_ALL=C sed 's/[^A-Za-z0-9-]/-/g')" + mkdir -p "$HOME/.claude/projects/$munged" + : > "$HOME/.claude/projects/$munged/$uuid.jsonl" +} + +@test "resurrect-panes.sh is executable (tmux-resurrect execs the hook directly)" { + # The hook is the @resurrect-hook-post-restore-all target, run directly (not + # via `bash <path>`), so a missing exec bit makes it die with Permission denied + # -- the restore succeeds but no pane gets seated. Guard the committed mode. + [ -x "$SCRIPTS/internal/resurrect-panes.sh" ] +} + +@test "plan: seats a role pane matched by its saved title" { + put_record agmsg aggie "sess-1" /proj + pane_line agmsg 0 0 "* agmsg-aggie" /proj bash "claude -n agmsg-aggie /agmsg actas aggie" + + run agmsg_resurrect_plan "$FIXTURE" + [ "$status" -eq 0 ] + # <target>\t<command>: target is session:window.pane + [[ "$output" == "agmsg:0.0"* ]] + [[ "$output" == *"claude"* ]] + [[ "$output" == *"-n agmsg-aggie"* ]] + [[ "$output" == *"actas"* ]] +} + +@test "plan: skips a role whose actas lock is held by a live session (#339)" { + # The role's owner is alive elsewhere (its record may have been sown from a + # still-running session in another process). Reseating would resume a uuid that + # is already open -> the CLI rejects the double-launch and the pane dies to a + # shell. The lock -- not "pane is a shell" -- is the source of truth here. + put_record agmsg aggie "sess-1" /proj + pane_line agmsg 0 0 "* agmsg-aggie" /proj bash "claude -n agmsg-aggie /agmsg actas aggie" + # A live actas-lock owner: cc-instance for this test's pid holds the owner sid. + echo "live-owner" > "$RUN_DIR/cc-instance.$$" + echo "live-owner" > "$(actas_lock_path agmsg aggie)" + + run agmsg_resurrect_plan "$FIXTURE" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "plan: still reseats when the lock is stale (owner sid dead) (#339)" { + # A dead owner (no live cc-instance references the sid) is a stale lock -> + # actas_lock_state reports free -> the role really needs reseating. + put_record agmsg aggie "sess-1" /proj + pane_line agmsg 0 0 "* agmsg-aggie" /proj bash "claude -n agmsg-aggie /agmsg actas aggie" + echo "dead-owner" > "$(actas_lock_path agmsg aggie)" # no cc-instance -> not alive + + run agmsg_resurrect_plan "$FIXTURE" + [[ "$output" == "agmsg:0.0"* ]] + [[ "$output" == *"-n agmsg-aggie"* ]] +} + +@test "plan: matches by the -n role marker in the saved argv when the title is generic" { + put_record agmsg worker "sess-2" /proj + pane_line agmsg 1 2 "zsh" /proj zsh "claude -n agmsg-worker /agmsg actas worker" + + run agmsg_resurrect_plan "$FIXTURE" + [[ "$output" == "agmsg:1.2"* ]] + [[ "$output" == *"-n agmsg-worker"* ]] +} + +@test "plan: adds --resume <uuid> when the recorded transcript still exists" { + put_record agmsg aggie "sess-1" /proj + make_transcript "sess-1" /proj + pane_line agmsg 0 0 "* agmsg-aggie" /proj bash "claude -n agmsg-aggie /agmsg actas aggie" + + run agmsg_resurrect_plan "$FIXTURE" + [[ "$output" == *"--resume sess-1"* ]] +} + +@test "plan: falls back to fresh (no --resume) when the transcript is gone" { + put_record agmsg aggie "sess-1" /proj # record but no transcript + pane_line agmsg 0 0 "* agmsg-aggie" /proj bash "claude -n agmsg-aggie /agmsg actas aggie" + + run agmsg_resurrect_plan "$FIXTURE" + [[ "$output" != *"--resume"* ]] + [[ "$output" == *"-n agmsg-aggie"* ]] # still seated, just fresh +} + +@test "plan: ignores panes that are not a role seat" { + put_record agmsg aggie "sess-1" /proj + pane_line agmsg 0 0 "vim" /proj vim "vim README.md" + + run agmsg_resurrect_plan "$FIXTURE" + [ -z "$output" ] +} + +@test "plan: a title that matches no record is not seated" { + put_record agmsg aggie "sess-1" /proj + pane_line agmsg 0 0 "* agmsg-ghost" /proj bash "bash" + + run agmsg_resurrect_plan "$FIXTURE" + [ -z "$output" ] +} + +@test "plan: empty when there are no role-session records" { + pane_line agmsg 0 0 "* agmsg-aggie" /proj bash "claude -n agmsg-aggie /agmsg actas aggie" + run agmsg_resurrect_plan "$FIXTURE" + [ -z "$output" ] +} + +@test "plan: empty when the save file is missing" { + put_record agmsg aggie "sess-1" /proj + run agmsg_resurrect_plan "$TEST_SKILL_DIR/does-not-exist.txt" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "plan: multiple role panes each get their own seat line" { + put_record agmsg aggie "sess-1" /proj + put_record agmsg worker "sess-2" /proj + pane_line agmsg 0 0 "* agmsg-aggie" /proj bash "claude -n agmsg-aggie /agmsg actas aggie" + pane_line agmsg 0 1 "* agmsg-worker" /proj bash "claude -n agmsg-worker /agmsg actas worker" + + run agmsg_resurrect_plan "$FIXTURE" + [ "$(printf '%s\n' "$output" | grep -c 'agmsg:0')" -eq 2 ] + [[ "$output" == *"agmsg:0.0"* ]] + [[ "$output" == *"agmsg:0.1"* ]] +} + +@test "save_file: prefers AGMSG_RESURRECT_SAVE override" { + : > "$FIXTURE" + AGMSG_RESURRECT_SAVE="$FIXTURE" run agmsg_resurrect_save_file + [ "$status" -eq 0 ] + [ "$output" = "$FIXTURE" ] +} + +@test "save_file: fails when nothing exists" { + AGMSG_RESURRECT_SAVE="" HOME="$TEST_SKILL_DIR/empty-home" run agmsg_resurrect_save_file + [ "$status" -ne 0 ] +} diff --git a/tests/test_role_session.bats b/tests/test_role_session.bats new file mode 100644 index 00000000..d2ba08a3 --- /dev/null +++ b/tests/test_role_session.bats @@ -0,0 +1,203 @@ +#!/usr/bin/env bats + +# Unit tests for the role->session record (#339 PR-A): +# - scripts/lib/role-session.sh primitives (record / read / lookup) +# - actas-claim.sh writes a record on successful claim, none on held +# The record is advisory runtime state keyed on the BARE session id (stable +# across resume generations), sharing the actas-lock filename encoding + run/ dir. + +load test_helper + +setup() { + setup_test_env + # Pin bare instance-id keying (#93) so actas-claim records the raw session_id + # these tests pass, deterministic whether the suite runs under an agent + # process (composite) or in CI (bare). + export AGMSG_AGENT_PID="" + export SKILL_DIR="$TEST_SKILL_DIR" + export RUN_DIR="$SKILL_DIR/run" + mkdir -p "$RUN_DIR" + # shellcheck disable=SC1090 + source "$SKILL_DIR/scripts/lib/role-session.sh" +} + +teardown() { teardown_test_env; } + +# Register a (team, agent) pair for the test project under claude-code. +fake_register() { + local team="$1" agent="$2" proj="${3:-/tmp/p1}" + bash "$SKILL_DIR/scripts/join.sh" "$team" "$agent" claude-code "$proj" +} + +# Fake that this test process owns a session_id (its own pid → live). +fake_session() { + local sid="$1" + echo "$sid" > "$RUN_DIR/cc-instance.$$" +} + +# --- path encoding (shares the actas-lock sanitizer) --- + +@test "record path: sits in run/ with role-session. prefix and __ separator" { + local p + p=$(_agmsg_role_session_path "T" "alice") + [[ "$p" == "$RUN_DIR/role-session.T__alice" ]] +} + +@test "record path: percent-encodes special bytes like the actas lock" { + local p + p=$(_agmsg_role_session_path "team/foo" "ag ent") + [[ "$p" == "$RUN_DIR/role-session.team%2Ffoo__ag%20ent" ]] +} + +@test "record path: encodes non-ASCII (UTF-8) team names" { + local p + p=$(_agmsg_role_session_path "チーム" alice) + [[ "$p" == *"%E3%83%81%E3%83%BC%E3%83%A0"* ]] +} + +# --- write / read roundtrip --- + +@test "record then uuid: roundtrips the bare session id" { + agmsg_role_session_record T alice "sid-abc" /tmp/p1 + [ "$(agmsg_role_session_uuid T alice)" = "sid-abc" ] +} + +@test "record: stores session/name/team/agent/type/project/updated_at fields" { + agmsg_role_session_record T alice "sid-abc" /tmp/proj claude-code + local f; f=$(_agmsg_role_session_path T alice) + grep -q "^session=sid-abc$" "$f" + grep -q "^name=T-alice$" "$f" + grep -q "^team=T$" "$f" + grep -q "^agent=alice$" "$f" + grep -q "^type=claude-code$" "$f" + grep -q "^project=/tmp/proj$" "$f" + grep -q "^updated_at=" "$f" +} + +@test "record: type is empty when omitted (back-compat 4-arg call)" { + agmsg_role_session_record T alice "sid-abc" /tmp/proj + local f; f=$(_agmsg_role_session_path T alice) + grep -q "^type=$" "$f" +} + +@test "get: reads back an arbitrary field (type)" { + agmsg_role_session_record T alice "sid-abc" /tmp/proj claude-code + [ "$(agmsg_role_session_get T alice type)" = "claude-code" ] + [ "$(agmsg_role_session_get T alice team)" = "T" ] +} + +@test "record: name= joins team and agent whole (halves may contain '-')" { + agmsg_role_session_record "team-x" "ag-1" "sid-1" /tmp/p + local f; f=$(_agmsg_role_session_path "team-x" "ag-1") + grep -q "^name=team-x-ag-1$" "$f" +} + +@test "record: latest write wins (overwrites prior record)" { + agmsg_role_session_record T alice "sid-old" /tmp/p1 + agmsg_role_session_record T alice "sid-new" /tmp/p1 + [ "$(agmsg_role_session_uuid T alice)" = "sid-new" ] +} + +@test "record: unicode team name roundtrips" { + agmsg_role_session_record "チーム" alice "sid-jp" /tmp/p1 + [ "$(agmsg_role_session_uuid "チーム" alice)" = "sid-jp" ] +} + +@test "uuid: empty when no record exists" { + [ -z "$(agmsg_role_session_uuid T nobody)" ] +} + +# --- best-effort / fail-open --- + +@test "record: empty sid is a no-op (writes nothing)" { + agmsg_role_session_record T alice "" /tmp/p1 + [ ! -f "$(_agmsg_role_session_path T alice)" ] +} + +@test "record: always returns 0 even when the run dir cannot be created" { + # Point SKILL_DIR at a path whose 'run' parent is a FILE, so mkdir -p fails. + local blocker="$TEST_SKILL_DIR/blocker" + : > "$blocker" # a regular file where a dir is needed + SKILL_DIR="$blocker" run agmsg_role_session_record T alice "sid-x" /tmp/p1 + [ "$status" -eq 0 ] +} + +# --- lookups (for PR-D / PR-E) --- + +@test "lookup_by_name: returns the record whose name= matches" { + agmsg_role_session_record T alice "sid-a" /tmp/p1 + agmsg_role_session_record T bob "sid-b" /tmp/p1 + local out; out=$(agmsg_role_session_lookup_by_name "T-bob") + echo "$out" | grep -q "^session=sid-b$" + echo "$out" | grep -q "^agent=bob$" +} + +@test "lookup_by_name: empty when no record matches" { + agmsg_role_session_record T alice "sid-a" /tmp/p1 + [ -z "$(agmsg_role_session_lookup_by_name "T-nobody")" ] +} + +@test "lookup_by_sid: returns the record whose session= matches" { + agmsg_role_session_record T alice "sid-a" /tmp/p1 + agmsg_role_session_record T bob "sid-b" /tmp/p1 + local out; out=$(agmsg_role_session_lookup_by_sid "sid-b") + echo "$out" | grep -q "^name=T-bob$" + echo "$out" | grep -q "^team=T$" + echo "$out" | grep -q "^agent=bob$" +} + +@test "lookup_by_sid: empty when no record matches" { + agmsg_role_session_record T alice "sid-a" /tmp/p1 + [ -z "$(agmsg_role_session_lookup_by_sid "sid-none")" ] +} + +# --- actas-claim.sh integration --- + +@test "actas-claim: writes a role-session record on successful claim" { + fake_register T alice + fake_session "sid-me" + + run bash "$SKILL_DIR/scripts/actas-claim.sh" /tmp/p1 claude-code alice "sid-me" + [ "$status" -eq 0 ] + [[ "$output" =~ "status=ok" ]] + [ "$(agmsg_role_session_uuid T alice)" = "sid-me" ] + # The claim knows the type, so the record captures it (for the resurrect hook). + [ "$(agmsg_role_session_get T alice type)" = "claude-code" ] +} + +@test "actas-claim: records the BARE sid when handed a composite instance id" { + fake_register T alice + # A live composite owner: cc-instance for our pid holds the composite token. + echo "sid-me.$$" > "$RUN_DIR/cc-instance.$$" + + run bash "$SKILL_DIR/scripts/actas-claim.sh" /tmp/p1 claude-code alice "sid-me.$$" + [ "$status" -eq 0 ] + # The record must strip the .<pid> — the bare sid is what survives resume. + [ "$(agmsg_role_session_uuid T alice)" = "sid-me" ] +} + +@test "actas-claim: held claim does NOT write a record for the thief" { + skip_on_windows "actas live-session liveness under Git Bash (#182)" + fake_register T alice + fake_session "sid-owner" # this process is the live owner + echo "sid-owner" > "$(actas_lock_path T alice)" + + run bash "$SKILL_DIR/scripts/actas-claim.sh" /tmp/p1 claude-code alice "sid-thief" + [ "$status" -eq 1 ] + [[ "$output" =~ "status=held" ]] + # No record was written (the thief never held the role). + [ ! -f "$(_agmsg_role_session_path T alice)" ] +} + +@test "actas-claim: record survives release + re-claim with the same sid" { + fake_register T alice + fake_session "sid-me" + + bash "$SKILL_DIR/scripts/actas-claim.sh" /tmp/p1 claude-code alice "sid-me" >/dev/null + [ "$(agmsg_role_session_uuid T alice)" = "sid-me" ] + + # Release the lock, then re-claim as the same session (resume keeps the sid). + actas_lock_release T alice "sid-me" + bash "$SKILL_DIR/scripts/actas-claim.sh" /tmp/p1 claude-code alice "sid-me" >/dev/null + [ "$(agmsg_role_session_uuid T alice)" = "sid-me" ] +} diff --git a/tests/test_spawn.bats b/tests/test_spawn.bats index d5481780..cecc90e8 100644 --- a/tests/test_spawn.bats +++ b/tests/test_spawn.bats @@ -161,6 +161,150 @@ teardown() { [[ "$output" == *"$PROJ"* ]] } +@test "spawn: names the session <team>-<agent> when the type has name_arg (#339)" { + bash "$SCRIPTS/join.sh" myteam existing claude-code "$PROJ" + run bash "$SCRIPTS/spawn.sh" claude-code alice --project "$PROJ" --no-wait + [ "$status" -eq 0 ] + + # claude-code's manifest declares name_arg=-n, so the boot script launches the + # CLI with `-n myteam-alice` (the resolved team joined to the agent name). + boot="$(cat "$CAPTURE")" + run cat "$boot" + [[ "$output" == *"-n myteam-alice"* ]] +} + +@test "spawn: boot script marks the session AGMSG_SPAWNED=1 (#339)" { + bash "$SCRIPTS/join.sh" myteam existing claude-code "$PROJ" + run bash "$SCRIPTS/spawn.sh" claude-code alice --project "$PROJ" --no-wait + [ "$status" -eq 0 ] + boot="$(cat "$CAPTURE")"; run cat "$boot" + # The spawned session carries the marker so the actas flow suppresses the + # hand-started "rename this session" tip. + [[ "$output" == *"export AGMSG_SPAWNED=1"* ]] +} + +@test "spawn: a type without name_arg emits no name flag (#339)" { + # gemini's manifest has no name_arg=, so the boot script must not name the + # session -- no bare `-n` token, unchanged from pre-#339 behavior. + bash "$SCRIPTS/join.sh" gteam existing gemini "$PROJ" + run bash "$SCRIPTS/spawn.sh" gemini bob --project "$PROJ" --no-wait + [ "$status" -eq 0 ] + + boot="$(cat "$CAPTURE")" + run cat "$boot" + [[ "$output" != *" -n "* ]] + [[ "$output" != *"gteam-bob"* ]] +} + +# Seed a role-session record + its transcript so spawn's resume path fires. +# Mirrors spawn's own project normalization + the driver's munging so the paths +# line up. With want_transcript=0 the record exists but the transcript does not +# (stale record → spawn must fall back to fresh). +seed_resumable() { + local team="$1" agent="$2" uuid="$3" proj="$4" want_transcript="${5:-1}" + local norm munged + export SKILL_DIR="$TEST_SKILL_DIR" # both libs below require it at source time + # shellcheck disable=SC1090 + source "$SCRIPTS/lib/resolve-project.sh" + norm="$(cd "$proj" && pwd)" + norm="$(agmsg_normalize_project_path "$norm")" + # shellcheck disable=SC1090 + source "$SCRIPTS/lib/role-session.sh" + agmsg_role_session_record "$team" "$agent" "$uuid" "$norm" + if [ "$want_transcript" -eq 1 ]; then + munged="$(printf '%s' "$norm" | LC_ALL=C sed 's/[^A-Za-z0-9-]/-/g')" + mkdir -p "$HOME/.claude/projects/$munged" + : > "$HOME/.claude/projects/$munged/$uuid.jsonl" + fi +} + +@test "spawn: resumes the role's prior session when record + transcript exist (#339)" { + bash "$SCRIPTS/join.sh" myteam existing claude-code "$PROJ" + seed_resumable myteam alice "sess-uuid-1" "$PROJ" 1 + + run bash "$SCRIPTS/spawn.sh" claude-code alice --project "$PROJ" --no-wait + [ "$status" -eq 0 ] + boot="$(cat "$CAPTURE")"; run cat "$boot" + # Resumed by uuid, still named after the role, still runs the actas prompt. + [[ "$output" == *"--resume sess-uuid-1"* ]] + [[ "$output" == *"-n myteam-alice"* ]] + [[ "$output" == *"actas"* ]] +} + +@test "spawn: --fresh forces a fresh session even when resumable (#339)" { + bash "$SCRIPTS/join.sh" myteam existing claude-code "$PROJ" + seed_resumable myteam alice "sess-uuid-1" "$PROJ" 1 + + run bash "$SCRIPTS/spawn.sh" claude-code alice --project "$PROJ" --no-wait --fresh + [ "$status" -eq 0 ] + boot="$(cat "$CAPTURE")"; run cat "$boot" + [[ "$output" != *"--resume"* ]] + [[ "$output" == *"-n myteam-alice"* ]] # naming still applies +} + +@test "spawn: falls back to fresh when the record's transcript is gone (#339)" { + bash "$SCRIPTS/join.sh" myteam existing claude-code "$PROJ" + seed_resumable myteam alice "sess-uuid-1" "$PROJ" 0 # record only, no transcript + + run bash "$SCRIPTS/spawn.sh" claude-code alice --project "$PROJ" --no-wait + [ "$status" -eq 0 ] + boot="$(cat "$CAPTURE")"; run cat "$boot" + [[ "$output" != *"--resume"* ]] +} + +@test "spawn: a fresh role (no record) boots fresh (#339)" { + bash "$SCRIPTS/join.sh" myteam existing claude-code "$PROJ" + run bash "$SCRIPTS/spawn.sh" claude-code alice --project "$PROJ" --no-wait + [ "$status" -eq 0 ] + boot="$(cat "$CAPTURE")"; run cat "$boot" + [[ "$output" != *"--resume"* ]] +} + +@test "spawn: a type without resume_arg never resumes (#339)" { + # gemini has no resume_arg in its manifest, so even with a record present the + # boot must be fresh (and gemini also has no name_arg, so no -n either). + bash "$SCRIPTS/join.sh" gteam existing gemini "$PROJ" + seed_resumable gteam bob "sess-uuid-9" "$PROJ" 1 + + run bash "$SCRIPTS/spawn.sh" gemini bob --project "$PROJ" --no-wait + [ "$status" -eq 0 ] + boot="$(cat "$CAPTURE")"; run cat "$boot" + [[ "$output" != *"--resume"* ]] +} + +@test "spawn: codex resumes via the 'resume' subcommand right after the cli (#339)" { + bash "$SCRIPTS/join.sh" cxteam existing codex "$PROJ" + # Record a codex role->session and a matching rollout (codex's transcript). + export SKILL_DIR="$TEST_SKILL_DIR" + # shellcheck disable=SC1090 + source "$SCRIPTS/lib/role-session.sh" + agmsg_role_session_record cxteam bob "cx-uuid-1" "$PROJ" codex + mkdir -p "$HOME/.codex/sessions/2026/07/05" + : > "$HOME/.codex/sessions/2026/07/05/rollout-2026-07-05T10-00-00-cx-uuid-1.jsonl" + + run bash "$SCRIPTS/spawn.sh" codex bob --project "$PROJ" --no-wait + [ "$status" -eq 0 ] + boot="$(cat "$CAPTURE")"; run cat "$boot" + # Subcommand shape: `codex resume cx-uuid-1 ...` -- resume token right after cli. + [[ "$output" == *"codex resume cx-uuid-1"* ]] + [[ "$output" == *"actas"* ]] + # codex has no name_arg, so no -n. + [[ "$output" != *" -n "* ]] +} + +@test "spawn: codex boots fresh when no rollout backs the record (#339)" { + bash "$SCRIPTS/join.sh" cxteam existing codex "$PROJ" + export SKILL_DIR="$TEST_SKILL_DIR" + # shellcheck disable=SC1090 + source "$SCRIPTS/lib/role-session.sh" + agmsg_role_session_record cxteam bob "cx-uuid-gone" "$PROJ" codex # record, no rollout + + run bash "$SCRIPTS/spawn.sh" codex bob --project "$PROJ" --no-wait + [ "$status" -eq 0 ] + boot="$(cat "$CAPTURE")"; run cat "$boot" + [[ "$output" != *"resume"* ]] +} + @test "spawn: boot script unsets the type's session-identity vars (#294)" { # A same-type spawn (claude-code from a claude-code session) must not leak the # parent's CLAUDE_CODE_SESSION_ID to the child, or the child mistakes the @@ -183,7 +327,7 @@ teardown() { @test "spawn: does NOT unset a type's credential/detect vars (#294)" { # The strip list is a dedicated spawn_unset_env=, NOT detect=. gemini's - # detect=GEMINI_API_KEY GOOGLE_GEMINI_CLI are credentials, not a session id — + # detect=GEMINI_CLI GEMINI_API_KEY: the session marker + a credential, not a session id — # stripping them would break the spawned child's auth (the opposite of the fix). # gemini has no spawn_unset_env=, so its boot script must emit no `unset` at all # and in particular must never unset GEMINI_API_KEY. @@ -536,6 +680,35 @@ YAML [ "$status" -ne 0 ] } +@test "spawn: '/'-prefixed boot prompt is guarded against MSYS path conversion" { + # On Git Bash / MSYS, an argv token starting with '/' is rewritten to a + # Windows path when handed to a native binary: '/agmsg actas alice' arrives + # as 'C:/Program Files/Git/agmsg actas alice'. The boot script must scope it + # out via MSYS2_ARG_CONV_EXCL on the CLI launch line (prefix-scoped, NOT + # MSYS_NO_PATHCONV=1, so genuine path args keep converting). + bash "$SCRIPTS/join.sh" myteam existing claude-code "$PROJ" + run bash "$SCRIPTS/spawn.sh" claude-code alice --project "$PROJ" --no-wait + [ "$status" -eq 0 ] + boot="$(cat "$CAPTURE")" + [ -f "$boot" ] + local cmd; cmd="$(basename "$TEST_SKILL_DIR")" + # The guard must sit on the same line as the CLI invocation, ahead of it. + run grep -E "^MSYS2_ARG_CONV_EXCL=/$cmd claude" "$boot" + [ "$status" -eq 0 ] +} + +@test "spawn: \$-prefixed boot prompt gets no MSYS guard (codex)" { + # '$'-prefixed prompts are not path-shaped, so no exclusion is emitted — + # keeps the boot script byte-identical for agentskills CLIs. + bash "$SCRIPTS/join.sh" myteam existing codex "$PROJ" + run bash "$SCRIPTS/spawn.sh" codex reviewer --project "$PROJ" + [ "$status" -eq 0 ] + boot="$(cat "$CAPTURE")" + [ -f "$boot" ] + run grep -F "MSYS2_ARG_CONV_EXCL" "$boot" + [ "$status" -ne 0 ] +} + @test "spawn: boot script keeps the .command suffix only on macOS (#282)" { # macOS `open -a Terminal` needs .command to execute the file; every other # launcher runs it via bash or its shebang, and on Windows .command makes @@ -656,3 +829,74 @@ YAML [[ "$output" == *"reviewer"* ]] [[ "$output" == *"REVIEW_THE_DIFF"* ]] } + +# --- #335: psmux on Windows cannot exec an extensionless boot script --- +# +# These fake `uname -s` (via a stub honoring $FAKE_UNAME_S) and stub `tmux` to +# capture its argv, so the Windows launch path is exercised on a Linux/macOS +# runner. On Windows the boot script must run through `bash -l`; elsewhere the +# bare path (shebang-honored by Unix tmux) is kept. + +@test "spawn: launch_in_tmux runs the boot script via bash -l on Windows (#335)" { + local cap="$TEST_SKILL_DIR/tmux-argv.txt" + : > "$cap" + cat > "$STUB_BIN/uname" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "${FAKE_UNAME_S:-Linux}" +EOF + chmod +x "$STUB_BIN/uname" + cat > "$STUB_BIN/tmux" <<EOF +#!/usr/bin/env bash +printf '%s\n' "\$*" >> "$cap" +case "\$1" in + new-window) echo '@1' ;; + split-window) echo '%1' ;; +esac +exit 0 +EOF + chmod +x "$STUB_BIN/tmux" + bash "$SCRIPTS/join.sh" myteam existing claude-code "$PROJ" + # Default target is a split pane. + run env TMUX="/tmp/fake,1,0" FAKE_UNAME_S="MINGW64_NT-10.0-19045" \ + bash "$SCRIPTS/spawn.sh" claude-code alice --project "$PROJ" --no-wait + [ "$status" -eq 0 ] + # A new window is the other branch. + run env TMUX="/tmp/fake,1,0" FAKE_UNAME_S="MINGW64_NT-10.0-19045" \ + bash "$SCRIPTS/spawn.sh" claude-code bob --project "$PROJ" --no-wait --window + [ "$status" -eq 0 ] + # Both branches must launch through `bash -l <boot>`, not the bare path. + run grep -E 'split-window .* bash -l /' "$cap" + [ "$status" -eq 0 ] + run grep -E 'new-window .* bash -l /' "$cap" + [ "$status" -eq 0 ] +} + +@test "spawn: launch_in_tmux keeps the bare boot path off Windows (#335)" { + local cap="$TEST_SKILL_DIR/tmux-argv.txt" + : > "$cap" + cat > "$STUB_BIN/uname" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "${FAKE_UNAME_S:-Linux}" +EOF + chmod +x "$STUB_BIN/uname" + cat > "$STUB_BIN/tmux" <<EOF +#!/usr/bin/env bash +printf '%s\n' "\$*" >> "$cap" +case "\$1" in + new-window) echo '@1' ;; + split-window) echo '%1' ;; +esac +exit 0 +EOF + chmod +x "$STUB_BIN/tmux" + bash "$SCRIPTS/join.sh" myteam existing claude-code "$PROJ" + run env TMUX="/tmp/fake,1,0" FAKE_UNAME_S="Linux" \ + bash "$SCRIPTS/spawn.sh" claude-code alice --project "$PROJ" --no-wait + [ "$status" -eq 0 ] + # Unix tmux honors the shebang, so no `bash -l` wrapper is emitted. + run grep -F 'bash -l' "$cap" + [ "$status" -ne 0 ] + # ...and the bare boot path is still the launched command. + run grep -E 'split-window .* /.*boot-' "$cap" + [ "$status" -eq 0 ] +} diff --git a/tests/test_storage.bats b/tests/test_storage.bats index d1f602a1..ad061c4a 100644 --- a/tests/test_storage.bats +++ b/tests/test_storage.bats @@ -70,6 +70,8 @@ SH # --- end-to-end roundtrip through the override --- @test "storage: send and inbox share the overridden db" { + bash "$SCRIPTS/join.sh" testteam alice claude-code /tmp/project-a + bash "$SCRIPTS/join.sh" testteam bob claude-code /tmp/project-b export AGMSG_STORAGE_PATH="$BATS_TEST_TMPDIR/store" bash "$SCRIPTS/send.sh" testteam alice bob "hi via override" [ -f "$AGMSG_STORAGE_PATH/messages.db" ] @@ -85,6 +87,7 @@ SH # Register an agent so check-inbox can resolve identity via whoami. bash "$SCRIPTS/join.sh" testteam alice claude-code "$project" + bash "$SCRIPTS/join.sh" testteam bob claude-code /tmp/agmsg-storage-test-bob # A message addressed to alice lives only in the overridden store. AGMSG_STORAGE_PATH="$store" bash "$SCRIPTS/send.sh" testteam bob alice "via override store" @@ -102,6 +105,8 @@ SH @test "storage: default db is untouched when the override is set" { # The default store was initialized in setup; writing through an override # must not add rows to it. + bash "$SCRIPTS/join.sh" testteam alice claude-code /tmp/project-a + bash "$SCRIPTS/join.sh" testteam bob claude-code /tmp/project-b export AGMSG_STORAGE_PATH="$BATS_TEST_TMPDIR/store" bash "$SCRIPTS/send.sh" testteam alice bob "isolated" @@ -136,7 +141,7 @@ SH # sends silently drop. With the wrapper they wait and all land. See #114. local x for x in 1 2 3 4 5 6 7 8 9 10; do - ( bash "$SCRIPTS/send.sh" team leader "tgt$x" "job $x" >/dev/null 2>&1 ) & + ( bash "$SCRIPTS/send.sh" team leader "tgt$x" "job $x" --force >/dev/null 2>&1 ) & done wait local n @@ -152,7 +157,7 @@ SH export AGMSG_STORAGE_PATH="$BATS_TEST_TMPDIR/freshstore" local x for x in 1 2 3 4 5 6 7 8 9 10; do - ( bash "$SCRIPTS/send.sh" team leader "tgt$x" "job $x" >/dev/null 2>&1 ) & + ( bash "$SCRIPTS/send.sh" team leader "tgt$x" "job $x" --force >/dev/null 2>&1 ) & done wait local n diff --git a/tests/test_transcript_exists.bats b/tests/test_transcript_exists.bats new file mode 100644 index 00000000..76c8cf25 --- /dev/null +++ b/tests/test_transcript_exists.bats @@ -0,0 +1,64 @@ +#!/usr/bin/env bats + +# Unit tests for the claude-code transcript-existence driver hook (#339 PR-C): +# scripts/drivers/types/claude-code/_transcript-exists.sh +# It answers "does a resumable session transcript exist for <uuid>?" by locating +# ~/.claude/projects/<munged-project>/<uuid>.jsonl. The munging (every char +# outside [A-Za-z0-9-] -> '-') is CLI-internal knowledge that lives in the driver. + +load test_helper + +setup() { + setup_test_env + # HOME is already sandboxed to $TEST_SKILL_DIR/home by setup_test_env. + # shellcheck disable=SC1090 + source "$TYPES/claude-code/_transcript-exists.sh" +} + +teardown() { teardown_test_env; } + +# Create the transcript file Claude Code would write for (uuid, project) under +# the sandboxed HOME, replicating the driver's munging so the paths line up. +make_transcript() { + local uuid="$1" project="$2" munged + munged="$(printf '%s' "$project" | LC_ALL=C sed 's/[^A-Za-z0-9-]/-/g')" + mkdir -p "$HOME/.claude/projects/$munged" + : > "$HOME/.claude/projects/$munged/$uuid.jsonl" +} + +@test "transcript_exists: true when the <uuid>.jsonl file is present" { + make_transcript "uuid-123" "/Users/me/proj" + agmsg_transcript_exists "uuid-123" "/Users/me/proj" +} + +@test "transcript_exists: false when the file is absent" { + ! agmsg_transcript_exists "no-such-uuid" "/Users/me/proj" +} + +@test "transcript_exists: munges '/', '.', and '_' all to '-'" { + # /tmp/munge_Test.dir -> -tmp-munge-Test-dir (case preserved, _ and . -> -). + local proj="/tmp/munge_Test.dir" + mkdir -p "$HOME/.claude/projects/-tmp-munge-Test-dir" + : > "$HOME/.claude/projects/-tmp-munge-Test-dir/u1.jsonl" + agmsg_transcript_exists "u1" "$proj" +} + +@test "transcript_exists: does not collapse runs of special chars" { + # A leading '.' after '/' yields '--' (verified against the real CLI layout). + local proj="/Users/me/.cfg" + mkdir -p "$HOME/.claude/projects/-Users-me--cfg" + : > "$HOME/.claude/projects/-Users-me--cfg/u2.jsonl" + agmsg_transcript_exists "u2" "$proj" +} + +@test "transcript_exists: empty uuid or project is not found" { + make_transcript "uuid-123" "/Users/me/proj" + ! agmsg_transcript_exists "" "/Users/me/proj" + ! agmsg_transcript_exists "uuid-123" "" +} + +@test "transcript_exists: unset HOME is not found (fail-open)" { + make_transcript "uuid-123" "/Users/me/proj" + HOME="" run agmsg_transcript_exists "uuid-123" "/Users/me/proj" + [ "$status" -ne 0 ] +} diff --git a/tests/test_type_registry.bats b/tests/test_type_registry.bats index ed7a8c90..8c8e38f6 100644 --- a/tests/test_type_registry.bats +++ b/tests/test_type_registry.bats @@ -82,7 +82,7 @@ write_node_launcher_fixtures() { g() { env -i PATH="$PATH" bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_get $1 $2"; } [ "$(g claude-code detect)" = "CLAUDE_CODE_SESSION_ID" ] [ "$(g codex detect)" = "CODEX_SANDBOX CODEX_THREAD_ID" ] - [ "$(g gemini detect)" = "GEMINI_API_KEY GOOGLE_GEMINI_CLI" ] + [ "$(g gemini detect)" = "GEMINI_CLI GEMINI_API_KEY" ] [ "$(g antigravity detect)" = "explicit" ] [ "$(g copilot detect)" = "explicit" ] [ "$(g opencode detect_proc)" = "opencode opencode-*" ] diff --git a/tests/test_watch.bats b/tests/test_watch.bats index 6d583c4d..677ab773 100644 --- a/tests/test_watch.bats +++ b/tests/test_watch.bats @@ -16,6 +16,7 @@ setup() { case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) export AGMSG_AGENT_PID="" ;; esac export PROJ="/tmp/agmsg-watch-proj" bash "$SCRIPTS/join.sh" team alice claude-code "$PROJ" >/dev/null + bash "$SCRIPTS/join.sh" team bob claude-code "$PROJ" >/dev/null } teardown() {