From 2f21afa570665c58ff294aaa9f0f7e8792de5034 Mon Sep 17 00:00:00 2001 From: fujibee Date: Wed, 8 Jul 2026 17:52:52 -0700 Subject: [PATCH 01/27] Role-to-session affinity: named sessions, resume-by-role boot, tmux-resurrect (#339) (#344) * feat(role-session): record (team, agent) -> session at actas-claim (#339) Add scripts/lib/role-session.sh: an advisory (team, agent) -> session record keyed on the BARE session id (stable across resume generations, unlike the composite instance-id the actas lock keys on). It shares the actas-lock filename encoding and run/ location; every write is best-effort (a failed record write never fails the claim) and every read is fail-open. actas-claim.sh writes the record on each successful claim, and none on the held/rollback path (the record means 'last session that held this role', not 'current owner' -- the lock answers that). Add agmsg_instance_bare_sid() to instance-id.sh to extract the bare sid from a composite token. First building block of role->session affinity: the moment a role is actas'd it becomes resumable back into its prior context. * feat(spawn): name spawned sessions - via name_arg (#339) Add a 'name_arg=' manifest key (claude-code: -n). spawn.sh's direct-CLI boot script now emits ' -' when the type declares it, so a spawned session is born named after its role -- meaningful in the prompt box and the /resume picker, and the marker the tmux-resurrect hook (PR-D) keys on. SESSION_NAME is computed after team resolution (a project-resolved --team is only known then). Types without name_arg skip naming (unchanged); the node-launcher branch is untouched (launchers own their CLI invocation). Docs: the claude-code actas flow now suggests '/rename -' for sessions not launched via spawn, so the picker and pane titles stay meaningful. * feat(spawn): resume-or-fresh boot for a role's prior session (#339) When a role has a recorded session (PR-A) whose transcript still exists, spawn now boots the type's CLI with ' ' so the role comes back into its prior conversational context instead of starting fresh; the actas prompt is still passed on the resume branch, since resume restores context only and the actas re-run re-establishes the watcher, the exclusivity lock, and the active FROM (claim is idempotent for the same session id). - claude-code manifest: add resume_arg=--resume. - New scripts/drivers/types/claude-code/_transcript-exists.sh: the CLI-internal ~/.claude/projects//.jsonl layout (every char outside [A-Za-z0-9-] -> '-', verified empirically) stays in the driver, never core. - spawn.sh: resolve resume_arg, add --fresh to force a fresh session, and gate resume on record-resolves AND transcript-exists (a stale record whose transcript was deleted falls back to fresh -- '--resume ' errors out). Verified before implementing (recorded in the PR): claude --resume with a positional prompt submits that prompt as the resumed session's first input (print + real interactive tmux run), and the session uuid is stable across a resume generation without --fork-session. * feat(resurrect): tmux-resurrect post-restore seat assignment (#339) After a tmux server restart, relaunch each role pane back into its session. Wired as @resurrect-hook-post-restore-all (NOT @resurrect-processes -- a verbatim argv re-run is wrong both ways: a fresh-boot argv starts another new session, a name-resume argv stalls on the picker). resurrect restores panes as plain shells; the hook resolves each role's CURRENT session at restore time and send-keys the same resume-or-fresh command spawn builds. - scripts/internal/resurrect-panes.sh: pure parse + command-construction core (agmsg_resurrect_plan, unit-tested against a fixture) + a live send-keys wrapper that only seats panes which restored as a shell. Matches a pane to a role by saved title or the '-n ' argv marker (whole name, never split). - lib/boot-command.sh: factor the boot-command pieces out of spawn.sh -- agmsg_actas_prompt / agmsg_role_resume_uuid / agmsg_role_cli_args -- so spawn and the resurrect hook build identical commands and cannot drift on flag order or the resume gate. spawn.sh now calls these (behavior unchanged; all spawn tests green). A design note records that a codex-style 'resume ' subcommand form would need a shape branch here (out of scope, follow-up). - role-session record gains a type= field (the hook needs it to rebuild the boot command from the type manifest); actas-claim writes it; add agmsg_role_session_get. - README: session-resume + tmux-resurrect setup (the two tmux.conf lines) and the fail-open / no-@resurrect-processes rationale. The end-to-end kill-server/restore path is a manual checklist item in the PR. * refactor(boot-command): one-key, cli-immediately-after resume convention (#339) Prepare the resume path to serve both a FLAG-shaped resume (claude-code's --resume ) and a SUBCOMMAND-shaped one (codex 0.142's resume ) from a SINGLE manifest key. The resume_arg value is now emitted verbatim right after the cli, immediately followed by the uuid -- mandatory position for a subcommand (must lead the argv), harmless for a flag (order-independent), so one emission order serves both with no per-shape branch and no second key. - Split agmsg_role_cli_args: agmsg_role_resume_head emits ' ' (the cli-immediately-after head); agmsg_role_cli_args now emits only the name/prompt tail. - spawn.sh and resurrect-panes.sh emit the head right after the cli, before model/spawn-options/name. claude behavior is unchanged (flags reorder freely); all spawn + resurrect tests stay green. - New tests/test_boot_command.bats locks the convention (head composes adjacently after the cli; tail carries no resume token) + the actas-prompt / resume gate. Replaces the earlier 'codex is a follow-up' design note with the one-key rule. * feat(session-start): role-aware directive on resume (#339) When a session's bare sid was recorded as a role's seat (by actas-claim) and that (team, agent) is registered for this project, SessionStart now emits the ROLE-FILTERED Monitor directive -- watch.sh with the 4th arg, which restricts receive to that role AND re-claims its exclusivity lock -- plus a role-aware note, instead of the generic unfiltered directive. This covers a manual `claude --resume ` that bypasses spawn's actas boot prompt: a resume restores context but not runtime state, so the resumed session re-arms as its role automatically. Fail-open: no record, an unreadable one, or a cross-project sid collision (the recorded (team, agent) is not one of this project's registered pairs) falls through to today's generic directive unchanged. * feat(codex): resume a codex role into its thread (#339) Extend session resume to codex, which resumes via a SUBCOMMAND (`codex resume [PROMPT]`, 0.142) rather than a flag. The one-key cli-immediately-after convention already handles the shape: manifest resume_arg=resume emits `codex resume ` right after the cli, then model/ prompt. Verified: codex resume loads prior context + submits the positional prompt, accepts -m after the SESSION_ID, and errors on a gone uuid (so the transcript pre-check is required). - codex/_transcript-exists.sh: locate ~/.codex/sessions/**/rollout-*-.jsonl (the uuid == session_meta payload.id == the resume SESSION_ID). Fail-open. - codex/codex-record-session.sh: the codex-side record path (codex actas is send-side only and never runs actas-claim). Resolves the thread id preferring $CODEX_THREAD_ID; the newest-rollout-by-cwd fallback records ONLY when the match is unique -- concurrent codex sessions in one cwd are ambiguous, so it records nothing and the role boots fresh (a resume mis-fire is worse than fresh). - codex/template.md: the actas flow now calls codex-record-session.sh, extending codex actas from send-side-only to also recording its resumable session. - No name_arg for codex (no session display-name flag); tmux-resurrect title matching therefore does not apply to codex, but spawn/despawn resume does. - README: session-resume now covers Claude Code + Codex. Tests: codex transcript-exists, the record path (env / unique-fallback / ambiguous-skip / no-match), and spawn emitting `codex resume `. * fix(codex): bash 3.2 compat + unset-HOME guard in codex-record-session (#339) macOS ships bash 3.2, whose parser cannot handle a while loop containing $(...) command substitutions when the whole loop is itself wrapped in an outer $(...) capture -- it mis-tracks the nesting and errors ('syntax error near unexpected token'). ubuntu's bash 5 parsed it fine, so only the macos bats leg failed. Rewrite the matching-rollout scan to write ids to a temp file instead of an outer tids=$(...) capture, keeping every $(...) un-nested (the same shape the existing agmsg_resolve_codex_thread uses). Also address a co1 nit: under 'set -u' an unset HOME made sessions_dir=$HOME/... an unbound-variable abort rather than the intended silent no-op; use ${HOME:-} and guard the dir check so it falls open to fresh. * fix(spawn): suppress the rename tip for spawn-launched sessions (#339) A dogfood run surfaced that the '/rename this session' tip fired even for sessions launched via spawn, which are already named - by name_arg -- so the tip is redundant and its 'hand-started' premise is wrong. spawn's boot script now exports AGMSG_SPAWNED=1 (type-independent, before the CLI exec), and the claude-code actas flow only suggests /rename when AGMSG_SPAWNED is unset (a hand-started session with no convention name). No behavior change for hand-started sessions. * fix(resurrect): set exec bit on directly-run #339 scripts (#339) Dogfood found scripts/internal/resurrect-panes.sh committed 100644. tmux- resurrect execs the @resurrect-hook-post-restore-all target DIRECTLY (not via 'bash '), so without the exec bit the hook dies with Permission denied -- the layout restores but no pane gets seated (silent). codex-record-session.sh had the same hole: the codex actas flow runs it as a bare path. Set both to 100755 via update-index --chmod=+x, matching the sibling convention (internal/init-db.sh, codex/codex-monitor.sh are 100755; sourced libs and the _-prefixed driver hooks stay 100644). Add a test asserting resurrect-panes.sh is executable (its scripts/internal/ dir is not force-chmod'd by the test harness, so the check is meaningful). * fix(resurrect): skip a role whose actas lock is held by a live session (#339) Dogfood (kill-server -> restore) found a role pane that failed to reseat: its role-session record had been sown from a session still LIVE in another process, so the hook tried to resume that uuid, the CLI rejected the double-launch, and the pane died back to a bare shell. The transcript-exists pre-check correctly returned true (the transcript exists -- the live session is writing it), and the 'pane restored as a shell' guard can't tell that the role's owner is alive elsewhere. The actas lock is the source of truth for that. resurrect-panes.sh now mirrors spawn.sh's pre-flight: before seating a role it checks actas_lock_state and skips when the lock is held by a live session (other:*). A dead owner leaves a stale lock -> reported free -> the role is reseated as before. Verified the app-dev1 artifacts: its record project matched the transcript's on-disk location (worktree cwd), ruling out a cross-project-dir transcript mismatch -- the lock was the sole cause. * docs(session-resurrect): tmux-resurrect setup + resume reference (#339) Per the requested structure: README keeps a minimal summary (sessions are named -, spawn resumes by default, tmux-resurrect one-liner) and links to a new long-form docs/session-resurrect.md next to actas.md. docs/session-resurrect.md covers: tmux-resurrect (+continuum) setup and the @resurrect-hook-post-restore-all line (with the do-NOT-use-@resurrect-processes warning); how records + the restore-time hook resolve each pane; full vs partial restore; a table of what auto-restores / is skipped (live-held role) / stays a bare shell / boots fresh; the manual claude --resume + actas fallback; and notes on resume-restores-context-only, --fresh, and the untrusted-dir trust prompt. English, neutral voice, generic examples (myteam-alice). --- README.md | 13 ++ docs/session-resurrect.md | 120 +++++++++++ scripts/actas-claim.sh | 13 ++ .../types/claude-code/_transcript-exists.sh | 29 +++ scripts/drivers/types/claude-code/template.md | 1 + scripts/drivers/types/claude-code/type.conf | 10 + .../drivers/types/codex/_transcript-exists.sh | 26 +++ .../types/codex/codex-record-session.sh | 85 ++++++++ scripts/drivers/types/codex/template.md | 3 +- scripts/drivers/types/codex/type.conf | 7 + scripts/internal/resurrect-panes.sh | 167 ++++++++++++++ scripts/lib/boot-command.sh | 89 ++++++++ scripts/lib/instance-id.sh | 15 ++ scripts/lib/role-session.sh | 154 +++++++++++++ scripts/session-start.sh | 51 ++++- scripts/spawn.sh | 89 +++++--- tests/test_boot_command.bats | 95 ++++++++ tests/test_codex_resume.bats | 107 +++++++++ tests/test_delivery.bats | 54 +++++ tests/test_instance_id.bats | 19 ++ tests/test_resurrect_panes.bats | 173 +++++++++++++++ tests/test_role_session.bats | 203 ++++++++++++++++++ tests/test_spawn.bats | 144 +++++++++++++ tests/test_transcript_exists.bats | 64 ++++++ 24 files changed, 1705 insertions(+), 26 deletions(-) create mode 100644 docs/session-resurrect.md create mode 100644 scripts/drivers/types/claude-code/_transcript-exists.sh create mode 100644 scripts/drivers/types/codex/_transcript-exists.sh create mode 100755 scripts/drivers/types/codex/codex-record-session.sh create mode 100755 scripts/internal/resurrect-panes.sh create mode 100644 scripts/lib/boot-command.sh create mode 100644 scripts/lib/role-session.sh create mode 100644 tests/test_boot_command.bats create mode 100644 tests/test_codex_resume.bats create mode 100644 tests/test_resurrect_panes.bats create mode 100644 tests/test_role_session.bats create mode 100644 tests/test_transcript_exists.bats diff --git a/README.md b/README.md index 51f644c1..5c86155a 100644 --- a/README.md +++ b/README.md @@ -226,6 +226,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 `. 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 `) and **Codex** +(`codex resume `); 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 `) 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 ` 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 `-` 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 # 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/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/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 ? +# +# Claude Code persists each session as +# ~/.claude/projects//.jsonl +# where 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 -> 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 `` only — other roles' inbound messages stop reaching this session until another `actas` or session end. 6. Set the session's active FROM to `` — use `` in every `send.sh` call for the rest of this session. 7. Tell the user: "Now acting as ``. Sends use `` as from; receive restricted to `` 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 `-` 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 `-` with `/rename -` 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 -` +# 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 ` 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 ? (#339) +# +# Codex persists each session as a "rollout" file: +# ~/.codex/sessions/YYYY/MM/DD/rollout--.jsonl +# The session UUID is the trailing component of the filename and is exactly the +# SESSION_ID `codex resume ` accepts (verified: it equals the +# session_meta payload.id). Unlike claude-code, the layout is date-partitioned, +# not cwd-keyed, so 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 ` errors out ("no rollout found") +# rather than starting fresh (verified). +# +# Defines: agmsg_transcript_exists -> 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-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 +# +# 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/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 codex "$(pwd)"`. For multiple teams, ask the user which team to join the new role into. 4. Set the session's active FROM to `` for every `send.sh` call until another `actas`. -5. Tell the user: "Now acting as ``. Sends will use `` 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 `` belongs to (from the identities output / the join above), then run `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/codex-record-session.sh "$(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 ` bring the role back into this conversation. +6. Tell the user: "Now acting as ``. Sends will use `` 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 [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 - (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/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 '/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 "/agmsg actas +# "`) would re-run as ANOTHER brand-new session; a pane saved with a +# name-based resume argv (`claude --resume `) 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 - +# 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 "\t" 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 "\t" 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 ` + # role marker in the saved argv (name_arg from the role's type). Match on the + # whole name (never split - 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 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/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: +# actas +# 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). 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//_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 : the manifest `resume_arg` value followed by +# , 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 [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 : [name_arg ] [prompt_arg] +# . 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 : strips the trailing +# "." of a composite "."; a bare "" 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 from the enclosing agent . # 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/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.__ +# with the SAME percent-encoding sanitizer, so these files sit next to the +# actas.__.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) -> for . 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= resumable session identity (stable across resume) +# name=- the -n display name; whole, so reverse lookup never +# has to split - apart (either half may +# itself contain '-') +# team= stored explicitly so by-sid consumers (PR-E) recover +# agent= the role without re-parsing the joined name +# 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= the resolved project root +# updated_at= 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 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 ` 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 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/session-start.sh b/scripts/session-start.sh index 3351f2fe..d1bae7b9 100755 --- a/scripts/session-start.sh +++ b/scripts/session-start.sh @@ -44,6 +44,8 @@ source "$SCRIPT_DIR/lib/hash.sh" source "$SCRIPT_DIR/lib/storage.sh" # shellcheck disable=SC1091 source "$SCRIPT_DIR/lib/type-registry.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) @@ -237,11 +239,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 arg restricts receive to that role AND re-claims its exclusivity +# lock. This covers a manual `claude --resume ` 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 arg. +if [ -n "$ROLE_NAME" ]; then + WATCH_COMMAND="$(printf '%q %q %q %q %q' "$WATCH" "$INSTANCE_ID" "$PROJECT" "$TYPE" "$ROLE_NAME")" + cat < | | | \`. 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 <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; } @@ -131,6 +140,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 @@ -147,6 +157,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 @@ -211,13 +222,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 `, copilot's `-i/--interactive `; 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 ` -`, 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 (-) 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 @@ -308,6 +330,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 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. @@ -346,16 +378,13 @@ 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: ` actas ` (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}" @@ -379,6 +408,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 - (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" @@ -398,20 +431,28 @@ esac printf ' --initial-input %q\n' "$ACTAS_PROMPT" else # Direct-CLI launch: - # ` [ ] [spawn-options...] [] "/ actas "`. + # ` [ ] [ ] [spawn-options...] [ ] [] "/ actas "`. # 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. + # The resume head (#339) is emitted RIGHT AFTER the cli, before all other + # args: mandatory for a subcommand-shaped resume (codex `resume `), + # harmless for a flag-shaped one (claude `--resume `) -- 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. printf '%s' "$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' 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: ... must yield + # `claude --resume ` 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 " { + # 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_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 ` (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_delivery.bats b/tests/test_delivery.bats index a307e2a5..df4d2fb0 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" ]] 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_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 `), 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 ] + # \t: 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 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 . — 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..9f18c5c9 100644 --- a/tests/test_spawn.bats +++ b/tests/test_spawn.bats @@ -161,6 +161,150 @@ teardown() { [[ "$output" == *"$PROJ"* ]] } +@test "spawn: names the session - 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 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 ?" by locating +# ~/.claude/projects//.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 .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 ] +} From 89980f9c79d0de6475f82041286568e3887e6f85 Mon Sep 17 00:00:00 2001 From: fujibee Date: Wed, 8 Jul 2026 17:53:02 -0700 Subject: [PATCH 02/27] fix(gemini): detect the real GEMINI_CLI env var, not GOOGLE_GEMINI_CLI (#351) Gemini CLI exports GEMINI_CLI=1 in its shell tool environment; the GOOGLE_GEMINI_CLI name in the manifest does not exist upstream, so gemini sessions fell through detection (misdetected as codex in the report). Keep GEMINI_API_KEY as the secondary signal. Root cause identified by the reporter with upstream source references. Closes #347 --- scripts/drivers/types/gemini/type.conf | 2 +- tests/test_spawn.bats | 2 +- tests/test_type_registry.bats | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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/tests/test_spawn.bats b/tests/test_spawn.bats index 9f18c5c9..4f086150 100644 --- a/tests/test_spawn.bats +++ b/tests/test_spawn.bats @@ -327,7 +327,7 @@ seed_resumable() { @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. 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-*" ] From 7b57d474ca0b26dd142ffdc58044ab369f36966b Mon Sep 17 00:00:00 2001 From: fujibee Date: Sat, 11 Jul 2026 15:56:01 -0700 Subject: [PATCH 03/27] fix(codex): bind the bridge to the role's recorded thread, not "loaded" (#350) (#353) The codex bridge launched with --thread loaded, which resolves to whatever conversation the app-server last touched. When a project cwd has run more than one codex thread, "loaded" is ambiguous and a co-resident thread can capture a role's messages -- they become turns on the wrong conversation, delivered nowhere the operator sees, while still marked read (issue #350, confirmed from a live bridge log: injected into a stale thread for many turns). codex-bridge-launcher.sh now prefers this role's RECORDED codex thread (the role-session record added in #339) when it exists AND is recorded for this project, falling back to "loaded" only when there is no record (fail-open for roles predating the record). A request-file thread still wins. Freshness holds because a role re-runs actas on resume (#339), which rewrites the record. The reuse check now also compares the bound thread (a new .thread state file alongside .appserver), so a bridge first launched on "loaded" rebinds the moment this role's record appears, instead of the app-server match alone keeping the wrong-thread bridge alive. Tests: launcher binds the recorded thread on a project match, falls back to loaded with no record / a cross-project record, and writes the bound-thread file. --- .../types/codex/codex-bridge-launcher.sh | 44 ++++++++++- tests/test_codex_bridge_launcher.bats | 74 +++++++++++++++++++ 2 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 tests/test_codex_bridge_launcher.bats diff --git a/scripts/drivers/types/codex/codex-bridge-launcher.sh b/scripts/drivers/types/codex/codex-bridge-launcher.sh index 6e6b31d2..8e12b066 100755 --- a/scripts/drivers/types/codex/codex-bridge-launcher.sh +++ b/scripts/drivers/types/codex/codex-bridge-launcher.sh @@ -28,6 +28,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 "teamname" lines for the project's codex roles @@ -59,6 +69,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 @@ -87,6 +102,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)" if [ -n "$bridge_pid" ] && kill -0 "$bridge_pid" 2>/dev/null; then @@ -96,13 +129,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 @@ -117,5 +156,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/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" <> "$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" ] +} From 8113d38a94d68e5092b818c8836d69b0b2b0d8e1 Mon Sep 17 00:00:00 2001 From: fujibee Date: Sat, 11 Jul 2026 16:09:04 -0700 Subject: [PATCH 04/27] Stop ancestor project resolution from over-reaching to $HOME / other teams (#357) (#359) * fix(resolve-project): stop ancestor resolution from over-reaching to $HOME / other teams (#357) The #92 ancestor walk resolves a session's project by climbing pwd's ancestors to the nearest REGISTERED project. Two flaws let a stray registration capture unrelated sessions (reported: a claude-code agent's project silently became $HOME): 1. agmsg_registered_projects scanned ALL teams, so a poison registration in an unrelated team leaked into another team's resolution. 2. the walk would MATCH $HOME / '/', which are ancestors of nearly every dir -- one registration there poisons every session beneath it. (codex was unaffected: the poison was a claude-code registration, and the registry scan is per-type.) Fixes (fallbacks only -- the per-process marker, step 1, is untouched): - Team-scoping: agmsg_resolve_project / _registered_projects / _ancestor_project / _gitcommon_project take an optional ; join.sh passes the join target team so its resolution only sees that team. Omitted team keeps the legacy all-teams scan (phased migration for team-agnostic callers: whoami/actas/watch/reset). - Shallow-path exclusion: agmsg_find_registered_project_variant never returns '/' or $HOME (the walk may ascend through them, it just won't land on one). - Source guard: join.sh refuses to register a project at '/' or $HOME. Tests: poison $HOME/other-team/'/' registrations no longer capture resolution; team scope returns only that team's projects (all-teams still works with no team); join refuses $HOME and root. Existing resolve suite stays green. * fix(resolve-project): normalize the shallow-path exclusion compare (#357 review) koit review: the '/' and $HOME match exclusion did a raw string compare, so a registration stored with a trailing/duplicate slash ('$HOME/', '//') would slip past it -- the ancestor walk generates a trailing-slash candidate variant, so such a poison IS matched, and the raw compare then failed to exclude it. Normalize the matched value before comparing (agmsg_normalize_project_path collapses duplicate slashes and strips a trailing one), so '$HOME/' and '//' resolve to $HOME and '/' and are excluded. join.sh's guard already ran on the normalized path; a trailing-slash $HOME still normalizes and is refused. Tests: '$HOME/' and '//' poison registrations are still excluded; join refuses a trailing-slash $HOME. (Normalization is logical, not symlink-resolving -- see the PR notes on that boundary.) * fix(resolve-project): drop the join $HOME/root registration guard (#357 review) koit decision: registering a project AT $HOME or / is a legitimate use case -- both claude and codex support sessions whose cwd is $HOME -- so join must not refuse or warn. The #357 protection stays entirely on the RESOLUTION side: - team-scoping (fix 1) and the ancestor-landing exclusion (fix 2) remain. - The exclusion means a $HOME registration only ever matches its EXACT path: a session started AT $HOME still resolves to $HOME (via the pwd fallback), while sessions in subdirectories beneath $HOME are NOT vacuumed up to it. Remove the join.sh guard and its tests; add tests asserting join allows a $HOME registration and that an exact-$HOME session still resolves $HOME. The normalized-compare robustness (trailing slash / '//') stays on the exclusion side only. --- scripts/join.sh | 9 +++- scripts/lib/resolve-project.sh | 54 ++++++++++++++----- tests/test_resolve_project.bats | 96 +++++++++++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 13 deletions(-) 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/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 , one per line. +# List distinct registered project paths for , one per line. An optional +# 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 +# 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 ; 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 (inclusive) that is a registered project # for . 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 +# Usage: agmsg_resolve_project [team] +# An optional 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/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() { # [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" < 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" { From af10ad080081284012349973cd4e1167a7735813 Mon Sep 17 00:00:00 2001 From: fujibee Date: Sat, 11 Jul 2026 17:52:05 -0700 Subject: [PATCH 05/27] fix(spawn): guard '/'-prefixed boot prompt against MSYS path conversion on Git Bash (#358) MSYS rewrites a leading-slash argv token to a Windows path when a native CLI is invoked: '/agmsg actas ' arrives as 'C:/Program Files/Git/agmsg actas ' and the spawned agent never sees a valid skill invocation. Emit MSYS2_ARG_CONV_EXCL=/ on the boot script's CLI launch line for '/'-prefixed types only: prefix-scoped so genuine POSIX-path args keep converting, inert outside MSYS, and '$'-prefixed boot scripts stay byte-identical (asserted by test). Co-authored-by: Salmonellasarduri Co-authored-by: Claude Fable 5 --- scripts/spawn.sh | 26 ++++++++++++++++++++++++-- tests/test_spawn.bats | 29 +++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/scripts/spawn.sh b/scripts/spawn.sh index 6cd28193..89c21ebf 100755 --- a/scripts/spawn.sh +++ b/scripts/spawn.sh @@ -390,6 +390,26 @@ if [ -n "$PROMPT" ]; then ${PROMPT}" fi +# Git Bash / MSYS path conversion rewrites exec args that look like absolute +# POSIX paths when invoking a native Windows binary: a '/ actas ' +# initial prompt reaches the CLI as 'C:/Program Files/Git/ actas ' +# 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 @@ -421,7 +441,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" @@ -441,7 +461,9 @@ esac # %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. - printf '%s' "$CLI_BIN" + # 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 diff --git a/tests/test_spawn.bats b/tests/test_spawn.bats index 4f086150..9e09d8da 100644 --- a/tests/test_spawn.bats +++ b/tests/test_spawn.bats @@ -680,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 From c1554b91dd26e72b7c607077ca1afffaf318ba29 Mon Sep 17 00:00:00 2001 From: masa6161 <14049926+masa6161@users.noreply.github.com> Date: Mon, 13 Jul 2026 06:34:07 +0900 Subject: [PATCH 06/27] fix(spawn): wrap boot script with bash -l for psmux on Windows (#335) (#363) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(spawn): explicit bash invocation for psmux on Windows (#335) psmux (Windows tmux-compatible multiplexer) hands the split-window / new-window command token to CreateProcess/ShellExecute. An extensionless boot script has no file association, so Windows shows an "Open with" dialog instead of executing it — the agent never starts. PR #329 gated the .command rename to Darwin (#282) but assumed tmux would honor the shebang ("runs it via its shebang (tmux)"). This holds for Unix tmux but not psmux: the root cause was left unaddressed and the symptom changed from Notepad to the "Open with" dialog. Prefix `bash -l` on Windows (MINGW/MSYS/CYGWIN) in launch_in_tmux(), matching launch_windows_terminal's existing `wt.exe new-tab bash -l` pattern. Applied to both new-window and split-window branches. macOS/Linux unchanged. Tested on psmux v3.3.4 + Windows 11: - codex spawn (default-shell=bash): OK - codex spawn (default-shell=pwsh): OK - claude-code spawn (default-shell=bash): OK Co-Authored-By: Claude Opus 4.6 (1M context) * Revert "fix(spawn): explicit bash invocation for psmux on Windows (#335)" This reverts commit 333a35957d1c474e9b94d2c63a7102ca197c03f7. * fix(windows): Codex phantom DB書き込みを防止 — SKILL.mdにbash実行指示追加・writable_rootsパス形式修正 Windows環境でspawnされたCodexエージェントが正しいskill DBに書き込めず、 phantom DB (C:\c\Users\...) やワークスペース内DBに誤って書き込む問題を修正。 根本原因: 1. SKILL.mdにシェル指定がなく、Codex LLMがPowerShellで直接実行を試みる → MSYS自動パス変換が効かず /c/Users/... が C:\c\Users\... に解決 2. install.shのconfigure_codex_sandbox()がMSYS形式パスをwritable_rootsに 書き込み、Codex Rustが正しくパスを解決できずsandboxがブロック 修正: - SKILL.md: bash実行指示とAGMSG_STORAGE_PATHオーバーライド禁止を追記 - install.sh: cygpath -mでWindows mixed形式に変換してからwritable_rootsに書き込み 検証: 3体同時Codex spawn × 2ラウンド、計17メッセージ全てreal DBに到達、 phantom/local DB流出ゼロ Co-Authored-By: Claude Opus 4.6 (1M context) * fix(spawn): wrap boot script with bash -l for psmux on Windows (#335) psmux launches processes via Windows APIs that do not process shebang lines. An extensionless boot script passed directly to tmux new-window/split-window is accepted but silently never executed. Wrap with `bash -l` on MINGW/MSYS/CYGWIN — the same pattern launch_windows_terminal already uses for wt.exe. Non-Windows platforms are unaffected (the array defaults to the bare script path). Tested on psmux 3.3.4 / MINGW64 / Windows 11: direct → FAIL, bash -l → PASS. Co-Authored-By: Claude Opus 4.6 (1M context) * test(spawn): cover the Windows tmux bash -l wrap (#335) Two bats tests that stub uname and tmux to exercise launch_in_tmux's Windows path: both new-window and split-window launch through bash -l on MINGW/MSYS/CYGWIN, and stay on the bare boot path elsewhere. --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: fujibee --- SKILL.md | 2 ++ install.sh | 10 ++++++ scripts/spawn.sh | 12 ++++++-- tests/test_spawn.bats | 71 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 2 deletions(-) diff --git a/SKILL.md b/SKILL.md index 1953f143..f6d2c257 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 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/scripts/spawn.sh b/scripts/spawn.sh index 89c21ebf..ebf26278 100755 --- a/scripts/spawn.sh +++ b/scripts/spawn.sh @@ -496,17 +496,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_spawn.bats b/tests/test_spawn.bats index 9e09d8da..cecc90e8 100644 --- a/tests/test_spawn.bats +++ b/tests/test_spawn.bats @@ -829,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" <> "$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 `, 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" <> "$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 ] +} From baa064ecf78c93ddf843c9d91a124e019eab1dcb Mon Sep 17 00:00:00 2001 From: fujibee Date: Sun, 12 Jul 2026 17:47:22 -0700 Subject: [PATCH 07/27] release: 1.1.7 (#376) * release: 1.1.7 * chore(release): map non-conventional squash subjects into the 1.1.7 changelog Two squash merges landed with bare PR titles (no feat:/fix: prefix), so git-cliff dropped the headline feature (#344) and a fix (#359) from the generated notes. Extend the existing non-conventional-subject parser section so both appear in CHANGELOG.md and the tag-time release notes. --- .claude-plugin/plugin.json | 2 +- CHANGELOG.md | 13 +++++++++++++ VERSION | 2 +- cliff.toml | 2 ++ package.json | 2 +- 5 files changed, 18 insertions(+), 3 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 27011d44..b7ab2244 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.7", "author": { "name": "fujibee" }, diff --git a/CHANGELOG.md b/CHANGELOG.md index d8f64590..9b429def 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ 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.7] - 2026-07-12 + +### 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 +245,7 @@ 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.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/VERSION b/VERSION index 0664a8fd..2bf1ca5f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.6 +1.1.7 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 = "Added" }, { message = "(?i)powershell launcher", group = "Added" }, { message = "(?i)sandboxed bash tool", group = "Fixed" }, + { message = "^Role-to-session affinity", group = "Added" }, + { message = "^Stop ancestor project resolution", group = "Fixed" }, # Everything else (merges, initial commit, misc) is intentionally dropped. { message = ".*", skip = true }, ] diff --git a/package.json b/package.json index c8f8b2e2..2c2eb52c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agmsg", - "version": "1.1.6", + "version": "1.1.7", "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" From 2202e5931c94700c2fdf374d35a23a3eec5da44e Mon Sep 17 00:00:00 2001 From: fujibee Date: Sun, 12 Jul 2026 19:00:14 -0700 Subject: [PATCH 08/27] fix(app-release): Authenticode-sign Windows binaries during tauri build (#354) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(app-release): Authenticode-sign windows binaries during tauri build Post-build in-place signing (azure/artifact-signing-action) mutated the exe/msi after the updater's minisign .sig had been generated, so every 0.1.4 in-app update failed signature verification, and the in-place pass corrupted the MSI's embedded cabinet (#333). Sign during bundling instead: bundle.windows.signCommand invokes app/scripts/sign-windows.ps1 (TrustedSigning PowerShell module, DefaultAzureCredential via azure/login OIDC — trusted-signing-cli is not usable here because it requires a client secret and the federated app registration has none). The app exe is now signed before cab packing and the .sig is computed over the final signed bytes. A post-build step verifies shipped msi/exe are Authenticode-signed so a silent signCommand regression cannot ship. * fix(app-release): make the no-post-build-signing regression test actually catch regressions co1 review on #354: tests/test_app_release_signing.bats's first test chained two bare `! grep -q ...` statements. bash's `set -e` (which bats enables for the test body) explicitly exempts `!`-negated commands from aborting on failure, so the first assertion's failure was silently swallowed — only the last statement's exit code decided the test's outcome. That's also why CI stayed green despite the workflow's own explanatory comment containing the literal string "artifact-signing-action", which the first assertion was supposed to reject. Fix: use `run` + explicit `[ "$status" -ne 0 ]` for each check (matches this repo's other bats files, which always place a bare negated grep as a test's LAST statement rather than chaining several). Also scope the assertion to the actual `uses: azure/artifact-signing-action` step wiring instead of the bare identifier, and reword the explanatory comment so it no longer echoes the old action's name — belt and suspenders against the same prose/assertion collision recurring. --- .github/workflows/app-release.yml | 73 ++++++++++++++++++++--------- app/scripts/sign-windows.ps1 | 30 ++++++++++++ tests/test_app_release_signing.bats | 49 +++++++++++++++++++ 3 files changed, 130 insertions(+), 22 deletions(-) create mode 100644 app/scripts/sign-windows.ps1 create mode 100644 tests/test_app_release_signing.bats 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/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/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" +} From 8cc69d4af500c5b17fc7155b17a85de3a5cc5105 Mon Sep 17 00:00:00 2001 From: fujibee Date: Sun, 12 Jul 2026 19:00:28 -0700 Subject: [PATCH 09/27] =?UTF-8?q?feat(app):=200.1.5=20UI=20polish=20?= =?UTF-8?q?=E2=80=94=20sidebar=20collapse,=20chat=20pane=20min/max,=20Team?= =?UTF-8?q?=20Room=20toggle,=20About=20version,=20lucide=20icons=20(#377)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(app): 0.1.5 UI polish — sidebar collapse, chat pane min/max, About version, lucide icons Three 0.1.5 backlog items bundled into one PR (koit: these are all minor display-type changes, ship together): - Sidebar (team pane) collapses to a 44px icon-only rail so panes get more width. Rail: agmsg mark (click to expand) → + (new team/agent, same menu as full view) → team icon (click opens a team-switch popup, replacing the 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. + const [showTeamRoom, setShowTeamRoom] = useState(true); // Toggled from the native "View > Show User Chat" menu item. const [showUserChat, setShowUserChat] = useState(true); + // 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>(new Set()); // Team-room history paging: whether an older page exists, and whether one @@ -170,6 +201,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 @@ -284,6 +355,27 @@ export default function App() { invoke("agmsg_command_name").then(setCmdName).catch(() => {}); }, []); + // Rust holds the only durable copy of these two flags (see view_visibility + // in lib.rs) — seed our state from there on mount rather than guessing + // `true`. Without this, a webview remount that doesn't restart the Rust + // process (Vite HMR during `tauri dev`, or any future webview reload) + // would reset these back to `true` while the native menu checkbox and + // Rust's own state kept whatever was last set, silently disagreeing. + useEffect(() => { + invoke<{ team_room: boolean; user_chat: boolean }>("view_visibility") + .then((v) => { + setShowTeamRoom(v.team_room); + setShowUserChat(v.user_chat); + }) + .catch(() => {}); + }, []); + + // Native "View > Show Team Room" menu checkbox toggles the room tab itself. + useEffect(() => { + const p = listen("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("toggle-user-chat", (e) => setShowUserChat(e.payload)); @@ -689,6 +781,17 @@ export default function App() { // here or offered as spawn/move targets. const teamWindows = useMemo(() => windows.filter((w) => w.team === team), [windows, team]); + // 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 // id, in the panes.map below and again for each window's dividers. @@ -809,8 +912,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 +951,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 @@ -910,12 +1027,7 @@ export default function App() { return (
{ - 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,134 +1098,263 @@ export default function App() {
)}
- -
+ {!sidebarCollapsed &&
}
-
@@ -1697,6 +1999,40 @@ export default function App() { ); })()} + {roomMenu && ( +
e.stopPropagation()}> + +
+ )} + + {chatMenu && ( +
e.stopPropagation()}> + +
+ )} + {windowMenu && (() => { const layouts: PaneLayout[] = ["vertical", "horizontal", "tile"]; diff --git a/app/src/i18n/locales/de.json b/app/src/i18n/locales/de.json index 82f68da1..4f440b3d 100644 --- a/app/src/i18n/locales/de.json +++ b/app/src/i18n/locales/de.json @@ -20,6 +20,8 @@ }, "sidebar": { "logoAlt": "agmsg", + "collapse": "Seitenleiste einklappen", + "expand": "Seitenleiste ausklappen", "newMenu": { "trigger": "+ Neu ▾", "team": "Team…", @@ -60,6 +62,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 +77,7 @@ } }, "composer": { - "asPrefix": "als", + "asLabel": "als {{appUser}}", "targetPlaceholder": "an…", "messagePlaceholder": "Nachricht", "sendButton": "senden", @@ -148,6 +154,12 @@ "layoutVertical": "Vertikal", "layoutHorizontal": "Horizontal", "layoutTile": "Kacheln" + }, + "room": { + "hide": "Team-Raum ausblenden" + }, + "chat": { + "hide": "Meinen Chat ausblenden" } }, "terminal": { @@ -175,6 +187,7 @@ "paste": "Einsetzen", "selectAll": "Alles auswählen", "viewMenu": "Darstellung", + "showTeamRoom": "Team-Raum anzeigen", "showUserChat": "Meinen Chat anzeigen", "paneLayout": "Bereichslayout", "paneLayoutVertical": "Vertikal", @@ -196,5 +209,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..5a58d39c 100644 --- a/app/src/i18n/locales/en.json +++ b/app/src/i18n/locales/en.json @@ -23,6 +23,8 @@ }, "sidebar": { "logoAlt": "agmsg", + "collapse": "Collapse sidebar", + "expand": "Expand sidebar", "newMenu": { "trigger": "+ New ▾", "team": "Team…", @@ -63,6 +65,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 +80,7 @@ } }, "composer": { - "asPrefix": "as", + "asLabel": "as {{appUser}}", "targetPlaceholder": "to…", "messagePlaceholder": "message", "sendButton": "send", @@ -151,6 +157,12 @@ "layoutVertical": "Vertical", "layoutHorizontal": "Horizontal", "layoutTile": "Tile" + }, + "room": { + "hide": "Hide Team Room" + }, + "chat": { + "hide": "Hide User Chat" } }, "terminal": { @@ -175,6 +187,7 @@ "paste": "Paste", "selectAll": "Select All", "viewMenu": "View", + "showTeamRoom": "Show Team Room", "showUserChat": "Show User Chat", "paneLayout": "Pane Layout", "paneLayoutVertical": "Vertical", @@ -196,5 +209,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..a88667a1 100644 --- a/app/src/i18n/locales/es.json +++ b/app/src/i18n/locales/es.json @@ -20,6 +20,8 @@ }, "sidebar": { "logoAlt": "agmsg", + "collapse": "Contraer barra lateral", + "expand": "Expandir barra lateral", "newMenu": { "trigger": "+ Nuevo ▾", "team": "Equipo…", @@ -60,6 +62,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 +77,7 @@ } }, "composer": { - "asPrefix": "como", + "asLabel": "como {{appUser}}", "targetPlaceholder": "para…", "messagePlaceholder": "mensaje", "sendButton": "enviar", @@ -148,6 +154,12 @@ "layoutVertical": "Vertical", "layoutHorizontal": "Horizontal", "layoutTile": "Mosaico" + }, + "room": { + "hide": "Ocultar sala de equipo" + }, + "chat": { + "hide": "Ocultar mi chat" } }, "terminal": { @@ -175,6 +187,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 +209,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..c06b0f83 100644 --- a/app/src/i18n/locales/fr.json +++ b/app/src/i18n/locales/fr.json @@ -20,6 +20,8 @@ }, "sidebar": { "logoAlt": "agmsg", + "collapse": "Réduire la barre latérale", + "expand": "Développer la barre latérale", "newMenu": { "trigger": "+ Nouveau ▾", "team": "Équipe…", @@ -60,6 +62,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 +77,7 @@ } }, "composer": { - "asPrefix": "en tant que", + "asLabel": "en tant que {{appUser}}", "targetPlaceholder": "à…", "messagePlaceholder": "message", "sendButton": "envoyer", @@ -148,6 +154,12 @@ "layoutVertical": "Vertical", "layoutHorizontal": "Horizontal", "layoutTile": "Mosaïque" + }, + "room": { + "hide": "Masquer la salle d'équipe" + }, + "chat": { + "hide": "Masquer mon chat" } }, "terminal": { @@ -175,6 +187,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 +209,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..05c02478 100644 --- a/app/src/i18n/locales/ja.json +++ b/app/src/i18n/locales/ja.json @@ -20,6 +20,8 @@ }, "sidebar": { "logoAlt": "agmsg", + "collapse": "サイドバーを折りたたむ", + "expand": "サイドバーを展開", "newMenu": { "trigger": "+ 新規 ▾", "team": "チーム…", @@ -60,6 +62,10 @@ "swapTitle": "クリックしてから、このタブ内の別のペインをクリックすると位置を入れ替えます" }, "chat": { + "title": "チャット", + "minimize": "最小化", + "maximize": "最大化", + "restore": "元に戻す", "peer": { "to": "→ {{to}}", "from": "{{from}} →" @@ -71,7 +77,7 @@ } }, "composer": { - "asPrefix": "として", + "asLabel": "{{appUser}} として", "targetPlaceholder": "宛先…", "messagePlaceholder": "メッセージ", "sendButton": "送信", @@ -148,6 +154,12 @@ "layoutVertical": "縦", "layoutHorizontal": "横", "layoutTile": "タイル" + }, + "room": { + "hide": "チームルームを非表示" + }, + "chat": { + "hide": "自分のチャットを非表示" } }, "terminal": { @@ -175,6 +187,7 @@ "paste": "ペースト", "selectAll": "すべてを選択", "viewMenu": "表示", + "showTeamRoom": "チームルームを表示", "showUserChat": "自分のチャットを表示", "paneLayout": "ペインレイアウト", "paneLayoutVertical": "縦", @@ -196,5 +209,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..ad13b5bd 100644 --- a/app/src/i18n/locales/ko.json +++ b/app/src/i18n/locales/ko.json @@ -20,6 +20,8 @@ }, "sidebar": { "logoAlt": "agmsg", + "collapse": "사이드바 접기", + "expand": "사이드바 펼치기", "newMenu": { "trigger": "+ 새로 만들기 ▾", "team": "팀…", @@ -60,6 +62,10 @@ "swapTitle": "클릭한 다음 이 탭의 다른 패널을 클릭하면 위치가 바뀝니다" }, "chat": { + "title": "채팅", + "minimize": "최소화", + "maximize": "최대화", + "restore": "복원", "peer": { "to": "→ {{to}}", "from": "{{from}} →" @@ -71,7 +77,7 @@ } }, "composer": { - "asPrefix": "발신", + "asLabel": "{{appUser}}(으)로", "targetPlaceholder": "받는 사람…", "messagePlaceholder": "메시지", "sendButton": "보내기", @@ -148,6 +154,12 @@ "layoutVertical": "세로", "layoutHorizontal": "가로", "layoutTile": "타일" + }, + "room": { + "hide": "팀 룸 숨기기" + }, + "chat": { + "hide": "내 채팅 숨기기" } }, "terminal": { @@ -175,6 +187,7 @@ "paste": "붙여넣기", "selectAll": "모두 선택", "viewMenu": "보기", + "showTeamRoom": "팀 룸 표시", "showUserChat": "내 채팅 표시", "paneLayout": "패널 레이아웃", "paneLayoutVertical": "세로", @@ -196,5 +209,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..2f8db077 100644 --- a/app/src/i18n/locales/pt-BR.json +++ b/app/src/i18n/locales/pt-BR.json @@ -20,6 +20,8 @@ }, "sidebar": { "logoAlt": "agmsg", + "collapse": "Recolher barra lateral", + "expand": "Expandir barra lateral", "newMenu": { "trigger": "+ Novo ▾", "team": "Equipe…", @@ -60,6 +62,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 +77,7 @@ } }, "composer": { - "asPrefix": "como", + "asLabel": "como {{appUser}}", "targetPlaceholder": "para…", "messagePlaceholder": "mensagem", "sendButton": "enviar", @@ -148,6 +154,12 @@ "layoutVertical": "Vertical", "layoutHorizontal": "Horizontal", "layoutTile": "Mosaico" + }, + "room": { + "hide": "Ocultar sala da equipe" + }, + "chat": { + "hide": "Ocultar meu chat" } }, "terminal": { @@ -175,6 +187,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 +209,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..76ff1965 100644 --- a/app/src/i18n/locales/zh-CN.json +++ b/app/src/i18n/locales/zh-CN.json @@ -20,6 +20,8 @@ }, "sidebar": { "logoAlt": "agmsg", + "collapse": "折叠侧边栏", + "expand": "展开侧边栏", "newMenu": { "trigger": "+ 新建 ▾", "team": "团队…", @@ -60,6 +62,10 @@ "swapTitle": "点击后再点击此标签页中的另一个面板即可交换位置" }, "chat": { + "title": "聊天", + "minimize": "最小化", + "maximize": "最大化", + "restore": "还原", "peer": { "to": "→ {{to}}", "from": "{{from}} →" @@ -71,7 +77,7 @@ } }, "composer": { - "asPrefix": "以", + "asLabel": "以 {{appUser}} 身份", "targetPlaceholder": "发送至…", "messagePlaceholder": "消息", "sendButton": "发送", @@ -148,6 +154,12 @@ "layoutVertical": "纵向", "layoutHorizontal": "横向", "layoutTile": "平铺" + }, + "room": { + "hide": "隐藏团队聊天室" + }, + "chat": { + "hide": "隐藏我的聊天" } }, "terminal": { @@ -175,6 +187,7 @@ "paste": "粘贴", "selectAll": "全选", "viewMenu": "显示", + "showTeamRoom": "显示团队聊天室", "showUserChat": "显示我的聊天", "paneLayout": "面板布局", "paneLayoutVertical": "纵向", @@ -196,5 +209,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..d25c29ea 100644 --- a/app/src/i18n/locales/zh-TW.json +++ b/app/src/i18n/locales/zh-TW.json @@ -20,6 +20,8 @@ }, "sidebar": { "logoAlt": "agmsg", + "collapse": "收合側邊欄", + "expand": "展開側邊欄", "newMenu": { "trigger": "+ 新增 ▾", "team": "團隊…", @@ -60,6 +62,10 @@ "swapTitle": "點擊後再點擊此分頁中的另一個面板即可交換位置" }, "chat": { + "title": "聊天", + "minimize": "最小化", + "maximize": "最大化", + "restore": "還原", "peer": { "to": "→ {{to}}", "from": "{{from}} →" @@ -71,7 +77,7 @@ } }, "composer": { - "asPrefix": "以", + "asLabel": "以 {{appUser}} 身分", "targetPlaceholder": "傳送給…", "messagePlaceholder": "訊息", "sendButton": "傳送", @@ -148,6 +154,12 @@ "layoutVertical": "縱向", "layoutHorizontal": "橫向", "layoutTile": "平鋪" + }, + "room": { + "hide": "隱藏團隊聊天室" + }, + "chat": { + "hide": "隱藏我的聊天" } }, "terminal": { @@ -175,6 +187,7 @@ "paste": "貼上", "selectAll": "全選", "viewMenu": "顯示", + "showTeamRoom": "顯示團隊聊天室", "showUserChat": "顯示我的聊天", "paneLayout": "窗格版面配置", "paneLayoutVertical": "縱向", @@ -196,5 +209,8 @@ "updateFailed": "更新失敗:{{error}}", "upToDate": "目前已是最新版本。", "noUpdatesTitle": "沒有可用更新" + }, + "stage": { + "emptyHint": "點擊左側的成員以啟動代理。" } } From 96f0abf6559f2f57068a5a737db57e257cf2685e Mon Sep 17 00:00:00 2001 From: fujibee Date: Sun, 12 Jul 2026 19:19:48 -0700 Subject: [PATCH 10/27] chore(app): bump AGMSG_CORE_REF to v1.1.7 (#379) --- app/AGMSG_CORE_REF | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 8a2fe6230f0370da329485338cf901e087101800 Mon Sep 17 00:00:00 2001 From: fujibee Date: Sun, 12 Jul 2026 21:13:51 -0700 Subject: [PATCH 11/27] chore(app): bump version to 0.1.5 (#380) --- app/package.json | 2 +- app/src-tauri/Cargo.lock | 2 +- app/src-tauri/Cargo.toml | 2 +- app/src-tauri/tauri.conf.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/package.json b/app/package.json index 8a702acf..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", diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 93aec21d..c607a07c 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", diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 12bc1ea0..574654bd 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" 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", From 04fea8fa80b969113e330d7e4c1b8fb18191b792 Mon Sep 17 00:00:00 2001 From: fujibee Date: Tue, 14 Jul 2026 01:17:01 -0700 Subject: [PATCH 12/27] feat(app): show agent and team status (#385) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(app): add bounded PTY detection tail * feat(app): emit detected agent state * feat(app): add agent status reducer * feat(app): show member agent status * feat(app): add multi-team status rail * fix(app): match blocked-prompt patterns across wrapped lines A narrow pane wraps a prompt like "Do you want to proceed?" across two terminal lines; the tail buffer previously joined lines with "\n", so the wrapped phrase never matched classify()'s substring patterns and the pane stayed stuck showing stale status instead of turning Blocked. Join with a space instead so a wrap can no longer split a pattern apart. * feat(app): show team names in the status rail, remove the redundant expanded select The expanded status rail now labels each row with its team name (previously dots only), making the existing setTeam(e.target.value)}> - {teams.map((teamName) => ( - - ))} - +
+
+ {teams.map((teamName) => { + const status = teamStatusByName[teamName] ?? "unknown"; + return ( + + ); + })}
{t("sidebar.title")} @@ -1269,46 +1328,58 @@ export default function App() { )}
    - {others.map((m) => ( -
  • { - e.preventDefault(); - e.stopPropagation(); - closeAllMenus(); - setMemberMenu({ member: m, x: e.clientX, y: e.clientY }); - }} - > - {active === "room" && ( - toggleMember(m.name)} - onClick={(e) => e.stopPropagation()} - /> - )} - -
  • - ))} + {active === "room" ? ( + toggleMember(m.name)} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + + {status && } + + )} + + + ); + })} {others.length === 0 && (
  • {t("sidebar.member.emptyState")}
  • )} @@ -1630,7 +1701,13 @@ export default function App() { ×
- + ); })} diff --git a/app/src/TerminalPane.tsx b/app/src/TerminalPane.tsx index 24903ba9..7150d8e4 100644 --- a/app/src/TerminalPane.tsx +++ b/app/src/TerminalPane.tsx @@ -12,6 +12,7 @@ type Props = { cmd: string; args?: string[]; cwd?: string; + onAgentState?: (id: string, state: "idle" | "working" | "blocked" | "unknown") => void; }; function b64ToBytes(b64: string): Uint8Array { @@ -25,7 +26,7 @@ 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, onAgentState }: Props) { const ref = useRef(null); const { t } = useTranslation(); @@ -88,7 +89,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 @@ -115,7 +120,7 @@ export function TerminalPane({ id, cmd, args = [], cwd }: Props) { void invoke("pty_kill", { id }); term.dispose(); }; - }, [id, cmd, cwd]); + }, [id, cmd, cwd, onAgentState]); return
; } 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; + +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 = { + blocked: 3, + working: 2, + idle: 1, + unknown: 0, + }; + return statuses.reduce( + (aggregate, status) => (priority[status.state] > priority[aggregate] ? status.state : aggregate), + "unknown", + ); +} From edbb37a90b3f683c295ab85d0100bccb57a166f9 Mon Sep 17 00:00:00 2001 From: fujibee Date: Tue, 14 Jul 2026 10:22:06 -0700 Subject: [PATCH 13/27] feat(app): snap pane dividers to terminal cell units, herdr-style gaps (#390) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(app): snap pane dividers to terminal cell units, herdr-style gaps Divider drags now snap to whole terminal columns/rows instead of free pixel positions — koit prefers the discrete, cell-aligned feel over a continuous drag. TerminalPane reports its actual cell size (px per col/row) on every fit; every pane shares the same fixed font today, so any one of them is representative for snapping math regardless of which two panes flank a given divider. The gap between panes is now derived from that same cell size (half a cell of padding per side, so two adjacent panes' padding sums to one full cell at the shared seam) instead of a fixed 4px/2px value — closer to herdr's look, and what koit was actually asking about when the old fixed gap read as narrower than herdr's. With the gap now wide enough to read clearly, the divider's own mid-gap hairline was redundant chrome — hidden by default, shown only on hover or while that SPECIFIC divider is being dragged (a class toggled on the divider element itself, not `resizing-col`/`row` on `body`, which is shared with the sidebar/chat dividers and would have lit up every same-axis pane divider during an unrelated drag). Also brightened the pane frame itself into its own `--pane-border` variable (koit: the shared `--border` was too faint to read as a visible box at rest) rather than changing `--border` globally, which 27 other rules also depend on. * fix(app): key the drag-highlight off divider identity, not a DOM ref co1 review (PR #390): the previous version toggled the highlight class directly on the grabbed divider's DOM element. For a 2x2 aligned grid that survives its own transpose, but a 3+ segment grid transposes into a different divider shape (grid-segment keys -> single keys), so React unmounts the original element mid-drag and the highlight silently disappears. dividerDragKey (now in paneTree.ts, pure and testable) computes a divider's identity as [...basePath, ...segmentPath] (or path directly for an already-single divider) — the same split node's path on both sides of the transpose, per transposeGrid's own doc. App.tsx now tracks the dragged divider by this key in state instead of a captured element, so the highlight survives regardless of which divider ends up rendering it. --- app/src/App.css | 21 +++++++--- app/src/App.tsx | 91 ++++++++++++++++++++++++++++++++-------- app/src/TerminalPane.tsx | 11 ++++- app/src/paneTree.test.ts | 29 +++++++++++++ app/src/paneTree.ts | 15 +++++++ 5 files changed, 142 insertions(+), 25 deletions(-) diff --git a/app/src/App.css b/app/src/App.css index 2888297d..278f202b 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; @@ -1029,7 +1034,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; } @@ -1123,7 +1128,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; @@ -1139,13 +1148,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; @@ -1268,7 +1279,7 @@ body.resizing-row { min-height: 0; width: 100%; height: auto; - border: 1px solid var(--border); + border: 1px solid var(--pane-border); border-top: none; } diff --git a/app/src/App.tsx b/app/src/App.tsx index fb2f98dd..3203f4be 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -28,6 +28,7 @@ import { clampRatio, collectDividers, computeRects, + dividerDragKey, insertAsNewLeaf, insertBeside, leaves, @@ -304,6 +305,26 @@ export default function App() { 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(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)); const appUser = appUserMember?.name ?? ""; @@ -1040,10 +1061,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)), ); @@ -1052,9 +1081,15 @@ 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); }, []); @@ -1582,6 +1617,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 @@ -1707,29 +1753,38 @@ export default function App() { args={p.args} cwd={p.cwd} onAgentState={applyAgentState} + onCellSize={handleCellSize} />
); })} {active !== "room" && - activeDividers.map((d) => ( -
startPaneDividerDrag(e, active, d)} - /> - ))} + activeDividers.map((d) => { + const isDragging = draggingDividerKey !== null && dividerDragKey(d) === draggingDividerKey; + return ( +
startPaneDividerDrag(e, active, d)} + /> + ); + })} {showUserChat && chatPaneState === "normal" && ( diff --git a/app/src/TerminalPane.tsx b/app/src/TerminalPane.tsx index 7150d8e4..242566b8 100644 --- a/app/src/TerminalPane.tsx +++ b/app/src/TerminalPane.tsx @@ -13,6 +13,9 @@ type Props = { args?: string[]; cwd?: string; 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 { @@ -26,7 +29,7 @@ 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, onAgentState }: Props) { +export function TerminalPane({ id, cmd, args = [], cwd, onAgentState, onCellSize }: Props) { const ref = useRef(null); const { t } = useTranslation(); @@ -63,6 +66,10 @@ export function TerminalPane({ id, cmd, args = [], cwd, onAgentState }: 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); }; const unlisteners: Array<() => void> = []; @@ -120,7 +127,7 @@ export function TerminalPane({ id, cmd, args = [], cwd, onAgentState }: Props) { void invoke("pty_kill", { id }); term.dispose(); }; - }, [id, cmd, cwd, onAgentState]); + }, [id, cmd, cwd, onAgentState, onCellSize]); return
; } 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).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 }; From b821759ccba3870097a925e4f7dcff2d675eac7f Mon Sep 17 00:00:00 2001 From: fujibee Date: Tue, 14 Jul 2026 13:25:11 -0700 Subject: [PATCH 14/27] feat(app): persist UI settings across restarts (#391) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(app): persist UI settings across restarts Persist to localStorage: Show Team Room / Show User Chat toggles, terminal font size (now adjustable from Settings), sidebar collapse state, and window size/position/maximized state (via tauri-plugin-window-state). Also adds a "don't show again" suppression for the auto-triggered app-user prompt on team switch. Currently selected team was already persisted (LAST_TEAM_KEY); no change needed there. Pane split ratios are left for a follow-up. * fix(app): restore view toggles via lazy init, not a racy setState effect co1 review on #391: showTeamRoom/showUserChat started at `true` and were corrected by a mount effect's setState — but a persist effect keyed on the same state captured the initial-render's stale `true` first and wrote it back to localStorage, corrupting the saved value on every restart. Lazily restore both from localStorage instead, matching the sidebarCollapsed/terminalFontSize pattern; the seeding effect now only pushes the (already-correct) initial value into Rust. Also validates the restored terminal font size against the Settings modal's own range (8-24) rather than just `> 0`, so a corrupted/stale localStorage value can't reach xterm or persist itself back unchecked. * fix(app): re-report cell size after a live font-size change co1 rebase-delta review on #391: the font-size effect calls fit.fit() (which changes xterm's internal rows/cols) but never called onCellSize, so #390's divider snap/gap sizing stayed pinned to whatever font was active on the .term-pane container's last actual resize — the ResizeObserver that normally drives onCellSize doesn't fire for a font change, since the container's own offsetWidth/offsetHeight don't move. --- app/src-tauri/Cargo.lock | 16 ++++ app/src-tauri/Cargo.toml | 1 + app/src-tauri/src/lib.rs | 5 ++ app/src/App.tsx | 146 ++++++++++++++++++++++++++------ app/src/TerminalPane.tsx | 47 +++++++++- app/src/i18n/locales/de.json | 5 +- app/src/i18n/locales/en.json | 5 +- app/src/i18n/locales/es.json | 5 +- app/src/i18n/locales/fr.json | 5 +- app/src/i18n/locales/ja.json | 5 +- app/src/i18n/locales/ko.json | 5 +- app/src/i18n/locales/pt-BR.json | 5 +- app/src/i18n/locales/zh-CN.json | 5 +- app/src/i18n/locales/zh-TW.json | 5 +- app/src/modals.tsx | 30 ++++++- 15 files changed, 251 insertions(+), 39 deletions(-) diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index c607a07c..4c95c2dd 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -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 574654bd..008d8d5d 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -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/lib.rs b/app/src-tauri/src/lib.rs index 0b333fef..04b7921c 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -498,6 +498,11 @@ 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 diff --git a/app/src/App.tsx b/app/src/App.tsx index 3203f4be..5f013a91 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -18,6 +18,8 @@ import { AgentModal, AppUserModal, ConfirmModal, + MAX_TERMINAL_FONT_SIZE, + MIN_TERMINAL_FONT_SIZE, NewTeamModal, RenameModal, SettingsModal, @@ -97,7 +99,7 @@ type DropPreview = { paneId: string; zone: ReturnType }; type Modal = | { kind: "team"; firstRun: boolean } | { kind: "agent" } - | { kind: "appuser" } + | { kind: "appuser"; auto: boolean } | { kind: "rename"; current: string } | { kind: "leave"; name: string } | { kind: "settings" } @@ -114,6 +116,22 @@ 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"; // 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 @@ -159,10 +177,24 @@ export default function App() { const [spawnTypes, setSpawnTypes] = useState([]); const [sidebarWidth, setSidebarWidth] = useState(200); const [chatHeight, setChatHeight] = useState(160); + // Terminal font size, adjustable from the Settings modal and persisted + // across restarts (see TERMINAL_FONT_SIZE_KEY). Validated against the same + // range the Settings 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; + }); // Collapses the team sidebar to an icon-only rail so panes get more width. - // Session-only (no persistence needed) — spawning/messaging a member isn't - // offered from the collapsed rail; expand to get back to the full list. - const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + // 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 enforces (see terminalFontSize's lazy +// useState initializer). +export const MIN_TERMINAL_FONT_SIZE = 8; +export const MAX_TERMINAL_FONT_SIZE = 24; + +export function SettingsModal(props: { + onClose: () => void; + terminalFontSize: number; + onTerminalFontSizeChange: (size: number) => void; +}) { const { t, i18n } = useTranslation(); return ( @@ -384,6 +391,21 @@ export function SettingsModal(props: { onClose: () => void }) { ))} +
{g.items.map((m) => (
@@ -1921,7 +1941,7 @@ export default function App() {
composerInputRef.current?.focus()}> {myThread.map((m) => (
- {m.created_at.slice(11, 19)} + {formatMessageTime(m.created_at, effectiveTimeZone)} {m.from === appUser ? t("chat.peer.to", { to: m.to }) @@ -2041,6 +2061,8 @@ export default function App() { onClose={() => setModal(null)} terminalFontSize={terminalFontSize} onTerminalFontSizeChange={setTerminalFontSize} + timezone={timezone} + onTimezoneChange={setTimezone} /> )} {modal?.kind === "closeWindow" && diff --git a/app/src/i18n/locales/de.json b/app/src/i18n/locales/de.json index a9b1048b..1100fd16 100644 --- a/app/src/i18n/locales/de.json +++ b/app/src/i18n/locales/de.json @@ -173,6 +173,10 @@ "title": "Einstellungen", "terminalFontSize": { "label": "Terminal-Schriftgröße" + }, + "timezone": { + "label": "Zeitzone", + "auto": "Automatisch ({{zone}})" } }, "nativeMenu": { diff --git a/app/src/i18n/locales/en.json b/app/src/i18n/locales/en.json index cb6416d3..e66daab2 100644 --- a/app/src/i18n/locales/en.json +++ b/app/src/i18n/locales/en.json @@ -10,6 +10,10 @@ "title": "Settings", "terminalFontSize": { "label": "Terminal font size" + }, + "timezone": { + "label": "Timezone", + "auto": "Auto ({{zone}})" } }, "startupError": { diff --git a/app/src/i18n/locales/es.json b/app/src/i18n/locales/es.json index b1311419..48d342f9 100644 --- a/app/src/i18n/locales/es.json +++ b/app/src/i18n/locales/es.json @@ -173,6 +173,10 @@ "title": "Configuración", "terminalFontSize": { "label": "Tamaño de fuente de la terminal" + }, + "timezone": { + "label": "Zona horaria", + "auto": "Automático ({{zone}})" } }, "nativeMenu": { diff --git a/app/src/i18n/locales/fr.json b/app/src/i18n/locales/fr.json index 6fb759a4..1fed0f81 100644 --- a/app/src/i18n/locales/fr.json +++ b/app/src/i18n/locales/fr.json @@ -173,6 +173,10 @@ "title": "Paramètres", "terminalFontSize": { "label": "Taille de police du terminal" + }, + "timezone": { + "label": "Fuseau horaire", + "auto": "Auto ({{zone}})" } }, "nativeMenu": { diff --git a/app/src/i18n/locales/ja.json b/app/src/i18n/locales/ja.json index 059c959c..c501a49f 100644 --- a/app/src/i18n/locales/ja.json +++ b/app/src/i18n/locales/ja.json @@ -173,6 +173,10 @@ "title": "設定", "terminalFontSize": { "label": "ターミナルのフォントサイズ" + }, + "timezone": { + "label": "タイムゾーン", + "auto": "自動 ({{zone}})" } }, "nativeMenu": { diff --git a/app/src/i18n/locales/ko.json b/app/src/i18n/locales/ko.json index eb3c88f7..6d63eada 100644 --- a/app/src/i18n/locales/ko.json +++ b/app/src/i18n/locales/ko.json @@ -173,6 +173,10 @@ "title": "설정", "terminalFontSize": { "label": "터미널 글꼴 크기" + }, + "timezone": { + "label": "시간대", + "auto": "자동 ({{zone}})" } }, "nativeMenu": { diff --git a/app/src/i18n/locales/pt-BR.json b/app/src/i18n/locales/pt-BR.json index f41cac97..138e0e2b 100644 --- a/app/src/i18n/locales/pt-BR.json +++ b/app/src/i18n/locales/pt-BR.json @@ -173,6 +173,10 @@ "title": "Configurações", "terminalFontSize": { "label": "Tamanho da fonte do terminal" + }, + "timezone": { + "label": "Fuso horário", + "auto": "Automático ({{zone}})" } }, "nativeMenu": { diff --git a/app/src/i18n/locales/zh-CN.json b/app/src/i18n/locales/zh-CN.json index b3b3ee7b..6f4cf599 100644 --- a/app/src/i18n/locales/zh-CN.json +++ b/app/src/i18n/locales/zh-CN.json @@ -173,6 +173,10 @@ "title": "设置", "terminalFontSize": { "label": "终端字体大小" + }, + "timezone": { + "label": "时区", + "auto": "自动 ({{zone}})" } }, "nativeMenu": { diff --git a/app/src/i18n/locales/zh-TW.json b/app/src/i18n/locales/zh-TW.json index b88b5a30..81c56c83 100644 --- a/app/src/i18n/locales/zh-TW.json +++ b/app/src/i18n/locales/zh-TW.json @@ -173,6 +173,10 @@ "title": "設定", "terminalFontSize": { "label": "終端機字型大小" + }, + "timezone": { + "label": "時區", + "auto": "自動 ({{zone}})" } }, "nativeMenu": { diff --git a/app/src/modals.tsx b/app/src/modals.tsx index a26e7563..99152229 100644 --- a/app/src/modals.tsx +++ b/app/src/modals.tsx @@ -1,7 +1,8 @@ -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; @@ -374,8 +375,38 @@ export function SettingsModal(props: { onClose: () => void; terminalFontSize: number; onTerminalFontSizeChange: (size: number) => void; + timezone: string; + onTimezoneChange: (timezone: string) => void; }) { const { t, i18n } = useTranslation(); + // 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 { + 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); + }} + /> + + +
- + {p.native ? t("pane.monitorTip.native") : t("pane.monitorTip.app")} - + ); From 403ff10ce000523209e9a813b6080677f0cae97d Mon Sep 17 00:00:00 2001 From: fujibee Date: Wed, 15 Jul 2026 14:10:12 -0700 Subject: [PATCH 23/27] feat(app): sidebar per-section + buttons, replacing the New dropdown (#407) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(app): replace the sidebar's + New dropdown with per-section + buttons koit's feedback: the expanded sidebar now has its own "Team" heading (new, mirrors the existing "Agents" one) with a + button that opens the new-team modal directly, and the "Agents" heading gets the same treatment for spawning an agent — no more picking Team vs Agent from a dropdown first. The collapsed rail keeps its dropdown as-is (no room for two separate headers at 44px wide). Member list's empty-state copy updated to match (pointed at the old dropdown flow); all 9 locales translated. * feat(app): close modals with Escape Added to the shared Modal wrapper (modals.tsx) so every modal that passes onClose gets it, not just the new-team/new-agent ones spawned from the new sidebar + buttons. * fix(app): don't close modals on Escape during IME composition The Escape-to-close handler only checked e.key, so canceling an IME conversion (kana->kanji, mid-composition) also closed the modal and dropped whatever draft it held. Guards on isComposing, the WKWebView keyCode-229 fallback, and defaultPrevented (so a child that already consumed the key keeps the modal open). Pulled into a pure shouldCloseOnEscape helper so the branching is unit-testable without mounting a component. --- app/src/App.css | 46 +++++++++++++++--------- app/src/App.tsx | 62 ++++++++++++++++----------------- app/src/i18n/locales/de.json | 7 +++- app/src/i18n/locales/en.json | 7 +++- app/src/i18n/locales/es.json | 7 +++- app/src/i18n/locales/fr.json | 7 +++- app/src/i18n/locales/ja.json | 7 +++- app/src/i18n/locales/ko.json | 7 +++- app/src/i18n/locales/pt-BR.json | 7 +++- app/src/i18n/locales/zh-CN.json | 7 +++- app/src/i18n/locales/zh-TW.json | 7 +++- app/src/modals.test.ts | 34 ++++++++++++++++++ app/src/modals.tsx | 28 +++++++++++++++ 13 files changed, 176 insertions(+), 57 deletions(-) create mode 100644 app/src/modals.test.ts diff --git a/app/src/App.css b/app/src/App.css index 210a5d08..2cbbbdac 100644 --- a/app/src/App.css +++ b/app/src/App.css @@ -160,22 +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; - white-space: nowrap; - cursor: pointer; -} -.new-btn:hover { - border-color: var(--accent); -} .new-menu { position: absolute; top: 110%; @@ -634,9 +623,6 @@ body.resizing-row { width: auto; display: block; } -.brand-row .new-wrap { - margin-left: auto; -} .sidebar-head .brand { font-size: 15px; } @@ -717,6 +703,34 @@ body.resizing-row { 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; align-items: center; diff --git a/app/src/App.tsx b/app/src/App.tsx index 16a4a64b..dbe45c90 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1404,39 +1404,20 @@ export default function App() {
{t("sidebar.logoAlt")} -
e.stopPropagation()}> - - {newMenu && ( -
- - -
- )} -
+
+ + {t("sidebar.team.title")} + + +
{teams.map((teamName) => { const status = teamStatusByName[teamName] ?? "unknown"; @@ -1456,7 +1437,24 @@ export default function App() { })}
- {t("sidebar.title")} + + {t("sidebar.title")} + + {active === "room" && others.length > 0 && ( diff --git a/app/src/i18n/locales/de.json b/app/src/i18n/locales/de.json index 1100fd16..a6652ea9 100644 --- a/app/src/i18n/locales/de.json +++ b/app/src/i18n/locales/de.json @@ -27,7 +27,12 @@ "team": "Team…", "agent": "Agent…" }, + "team": { + "title": "Team", + "new": "Neues Team" + }, "title": "Agenten", + "newAgent": "Neuer Agent", "filter": { "all": "alle", "none": "keine" @@ -37,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}}" diff --git a/app/src/i18n/locales/en.json b/app/src/i18n/locales/en.json index e66daab2..00c581a9 100644 --- a/app/src/i18n/locales/en.json +++ b/app/src/i18n/locales/en.json @@ -37,7 +37,12 @@ "team": "Team…", "agent": "Agent…" }, + "team": { + "title": "Team", + "new": "New team" + }, "title": "Agents", + "newAgent": "New agent", "filter": { "all": "all", "none": "none" @@ -47,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}}" diff --git a/app/src/i18n/locales/es.json b/app/src/i18n/locales/es.json index 48d342f9..4f7649f3 100644 --- a/app/src/i18n/locales/es.json +++ b/app/src/i18n/locales/es.json @@ -27,7 +27,12 @@ "team": "Equipo…", "agent": "Agente…" }, + "team": { + "title": "Equipo", + "new": "Nuevo equipo" + }, "title": "Agentes", + "newAgent": "Nuevo agente", "filter": { "all": "todos", "none": "ninguno" @@ -37,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}}" diff --git a/app/src/i18n/locales/fr.json b/app/src/i18n/locales/fr.json index 1fed0f81..46198cc4 100644 --- a/app/src/i18n/locales/fr.json +++ b/app/src/i18n/locales/fr.json @@ -27,7 +27,12 @@ "team": "Équipe…", "agent": "Agent…" }, + "team": { + "title": "Équipe", + "new": "Nouvelle équipe" + }, "title": "Agents", + "newAgent": "Nouvel agent", "filter": { "all": "tous", "none": "aucun" @@ -37,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}}" diff --git a/app/src/i18n/locales/ja.json b/app/src/i18n/locales/ja.json index c501a49f..587e63f1 100644 --- a/app/src/i18n/locales/ja.json +++ b/app/src/i18n/locales/ja.json @@ -27,7 +27,12 @@ "team": "チーム…", "agent": "エージェント…" }, + "team": { + "title": "チーム", + "new": "新規チーム" + }, "title": "エージェント", + "newAgent": "新規エージェント", "filter": { "all": "すべて", "none": "なし" @@ -37,7 +42,7 @@ "titleRunning": "実行中 — クリックしてフォーカス", "titleSpawn": "PTYペインで起動", "noTypes": "—", - "emptyState": "エージェントはまだありません。+ 新規 → エージェント から追加してください。" + "emptyState": "エージェントはまだありません。上の+ボタンから追加してください。" }, "user": { "title": "{{team}} のapp-user" diff --git a/app/src/i18n/locales/ko.json b/app/src/i18n/locales/ko.json index 6d63eada..90a86390 100644 --- a/app/src/i18n/locales/ko.json +++ b/app/src/i18n/locales/ko.json @@ -27,7 +27,12 @@ "team": "팀…", "agent": "에이전트…" }, + "team": { + "title": "팀", + "new": "새 팀" + }, "title": "에이전트", + "newAgent": "새 에이전트", "filter": { "all": "전체", "none": "없음" @@ -37,7 +42,7 @@ "titleRunning": "실행 중 — 클릭하면 포커스", "titleSpawn": "PTY 창에서 실행", "noTypes": "—", - "emptyState": "아직 에이전트가 없습니다. + 새로 만들기 → 에이전트를 사용하세요." + "emptyState": "아직 에이전트가 없습니다. 위의 + 버튼을 사용해 추가하세요." }, "user": { "title": "{{team}}의 app-user" diff --git a/app/src/i18n/locales/pt-BR.json b/app/src/i18n/locales/pt-BR.json index 138e0e2b..ab10b4ed 100644 --- a/app/src/i18n/locales/pt-BR.json +++ b/app/src/i18n/locales/pt-BR.json @@ -27,7 +27,12 @@ "team": "Equipe…", "agent": "Agente…" }, + "team": { + "title": "Equipe", + "new": "Nova equipe" + }, "title": "Agentes", + "newAgent": "Novo agente", "filter": { "all": "todos", "none": "nenhum" @@ -37,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}}" diff --git a/app/src/i18n/locales/zh-CN.json b/app/src/i18n/locales/zh-CN.json index 6f4cf599..fd603451 100644 --- a/app/src/i18n/locales/zh-CN.json +++ b/app/src/i18n/locales/zh-CN.json @@ -27,7 +27,12 @@ "team": "团队…", "agent": "智能体…" }, + "team": { + "title": "团队", + "new": "新建团队" + }, "title": "智能体", + "newAgent": "新建智能体", "filter": { "all": "全部", "none": "无" @@ -37,7 +42,7 @@ "titleRunning": "已在运行 — 点击以聚焦", "titleSpawn": "在 PTY 面板中启动", "noTypes": "—", - "emptyState": "暂无智能体。使用 + 新建 → 智能体。" + "emptyState": "暂无智能体。使用上方的 + 按钮添加。" }, "user": { "title": "{{team}} 中的 app-user" diff --git a/app/src/i18n/locales/zh-TW.json b/app/src/i18n/locales/zh-TW.json index 81c56c83..822e31c8 100644 --- a/app/src/i18n/locales/zh-TW.json +++ b/app/src/i18n/locales/zh-TW.json @@ -27,7 +27,12 @@ "team": "團隊…", "agent": "代理…" }, + "team": { + "title": "團隊", + "new": "新增團隊" + }, "title": "代理", + "newAgent": "新增代理", "filter": { "all": "全部", "none": "無" @@ -37,7 +42,7 @@ "titleRunning": "已在執行中 — 點選以切換至該面板", "titleSpawn": "在 PTY 面板中啟動", "noTypes": "—", - "emptyState": "尚無代理。請使用「+ 新增 → 代理」新增。" + "emptyState": "尚無代理。請使用上方的「+」按鈕新增。" }, "user": { "title": "{{team}} 的 app-user" diff --git a/app/src/modals.test.ts b/app/src/modals.test.ts new file mode 100644 index 00000000..3b7f33d3 --- /dev/null +++ b/app/src/modals.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { shouldCloseOnEscape } 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); + }); +}); diff --git a/app/src/modals.tsx b/app/src/modals.tsx index 8c31d581..5a956e90 100644 --- a/app/src/modals.tsx +++ b/app/src/modals.tsx @@ -6,12 +6,40 @@ import { AUTO_TIMEZONE, detectTimeZone, isValidTimeZone, listTimeZones } from ". type BrowseDir = (current: string) => Promise; +/** + * 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): 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 (
e.stopPropagation()}> From ca2263dfce64957d2bf6379c8a7c721b257f04db Mon Sep 17 00:00:00 2001 From: fujibee Date: Wed, 15 Jul 2026 14:10:15 -0700 Subject: [PATCH 24/27] fix(antigravity): resolve Codex leftovers and delivery_modes mismatch (#408) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit template.md had three copy-paste leftovers referring to Codex instead of Antigravity (delivery-mode picker note, actas confirmation, mode-set rejection message). Separately, type.conf declared delivery_modes=monitor turn both off, but antigravity has no Monitor tool or bridge equivalent (unlike codex/grok-build, which do) — the template only ever offered turn/off and hard-rejected monitor/both. Narrow type.conf to turn/off to match what's actually implemented. Closes #399. --- scripts/drivers/types/antigravity/template.md | 6 ++--- scripts/drivers/types/antigravity/type.conf | 2 +- tests/test_delivery.bats | 25 +++++++++++++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) 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 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 antigravity "$(pwd)"`. For multiple teams, ask the user which team to join the new role into. 4. Set the session's active FROM to `` for every `send.sh` call until another `actas`. -5. Tell the user: "Now acting as ``. Sends will use `` 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 ``. Sends will use `` 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 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/tests/test_delivery.bats b/tests/test_delivery.bats index df4d2fb0..3c912d0c 100644 --- a/tests/test_delivery.bats +++ b/tests/test_delivery.bats @@ -1446,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 From e26e80e0e97017c05536996297c374e78ad18f2e Mon Sep 17 00:00:00 2001 From: fujibee Date: Wed, 15 Jul 2026 14:16:33 -0700 Subject: [PATCH 25/27] fix(send): reject unregistered from/to agents (#409) * fix(send): reject unregistered from/to agents send.sh performed no existence check on either the from or to agent, so a typo'd recipient (e.g. a stray send to "dummy") was accepted and stored: the message landed addressed to nobody, was never deliverable, and polluted team history with no signal at all (observed live: exit 0 on a mistyped send). Validate both from and to against the team's roster before inserting, listing the registered names in the error so a typo is obvious. --force bypasses this for intentional pre-registration sends. Validation lives in send.sh (the front door), not storage.sh, so other entry points (api.sh) can keep their own policy. Several existing tests sent through team/agent pairs that were never joined (a shortcut that only worked because there was no validation); updated those to join first, or pass --force where the test is actually about concurrency/storage behavior rather than registration. Closes #355. * fix(send): document --force + roster validation, add regression tests Address co1 review on #409: - README.md / README.ja.md / SKILL.md described send.sh as taking exactly four positional arguments; document the optional trailing --force and the roster-validation contract. - Add direct regression coverage for the new contract in test_messaging.bats: unregistered from/to are rejected without an insert, the error lists the registered roster, and --force bypasses the check even with no team config at all. --- README.ja.md | 4 ++-- README.md | 4 ++-- SKILL.md | 4 ++-- scripts/send.sh | 50 +++++++++++++++++++++++++++++++++++++-- tests/test_despawn.bats | 3 +++ tests/test_install.bats | 4 ++++ tests/test_messaging.bats | 36 ++++++++++++++++++++++++++++ tests/test_storage.bats | 9 +++++-- tests/test_watch.bats | 1 + 9 files changed, 105 insertions(+), 10 deletions(-) diff --git a/README.ja.md b/README.ja.md index b0501179..522cdb3a 100644 --- a/README.ja.md +++ b/README.ja.md @@ -315,7 +315,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 @@ -325,7 +325,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 5c86155a..0220131f 100644 --- a/README.md +++ b/README.md @@ -329,7 +329,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 @@ -339,7 +339,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 f6d2c257..76662699 100644 --- a/SKILL.md +++ b/SKILL.md @@ -58,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/scripts/send.sh b/scripts/send.sh index 4716bdc2..3f41e0ab 100755 --- a/scripts/send.sh +++ b/scripts/send.sh @@ -1,12 +1,16 @@ #!/usr/bin/env bash set -euo pipefail -# Usage: send.sh +# Usage: send.sh [--force] -TEAM="${1:?Usage: send.sh }" +TEAM="${1:?Usage: send.sh [--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 — 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/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_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_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_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() { From e1998b0d400a50f508b9871a91b34af20275575a Mon Sep 17 00:00:00 2001 From: fujibee Date: Wed, 15 Jul 2026 14:17:53 -0700 Subject: [PATCH 26/27] fix(app): filter non-numeric characters out of the font-size draft (#405) * fix(app): filter non-numeric characters out of the font-size draft * fix(app): switch font-size input off type=number, fix IME + restore arrow-key stepping * fix(app): don't hijack IME candidate navigation in font-size arrow-key stepping --- app/src/App.css | 14 ----- app/src/modals.test.ts | 69 +++++++++++++++++++++++- app/src/modals.tsx | 118 +++++++++++++++++++++++++++++++++++++---- 3 files changed, 177 insertions(+), 24 deletions(-) diff --git a/app/src/App.css b/app/src/App.css index 2cbbbdac..ba6af2a7 100644 --- a/app/src/App.css +++ b/app/src/App.css @@ -325,20 +325,6 @@ select:focus { .modal select { width: 100%; } -/* The native number-input spinner (up/down arrows) renders as unstyled - * browser chrome that doesn't pick up the app's dark theme — hide it. - * (This field also sets step="any" for free decimal typing, so keyboard - * ArrowUp/ArrowDown stepping is UA-dependent regardless of this rule — - * typing/pasting a value is the guaranteed path either way.) */ -.modal input[type="number"] { - -moz-appearance: textfield; - appearance: textfield; -} -.modal input[type="number"]::-webkit-inner-spin-button, -.modal input[type="number"]::-webkit-outer-spin-button { - -webkit-appearance: none; - margin: 0; -} .path-row { display: flex; gap: 6px; diff --git a/app/src/modals.test.ts b/app/src/modals.test.ts index 3b7f33d3..92167cbc 100644 --- a/app/src/modals.test.ts +++ b/app/src/modals.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { shouldCloseOnEscape } from "./modals"; +import { sanitizeNumberDraft, shouldCloseOnEscape, stepFontSize } from "./modals"; function esc(overrides: Partial<{ isComposing: boolean; keyCode: number; defaultPrevented: boolean }> = {}) { return { @@ -32,3 +32,70 @@ describe("shouldCloseOnEscape", () => { 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 5a956e90..367c0ff7 100644 --- a/app/src/modals.tsx +++ b/app/src/modals.tsx @@ -399,6 +399,48 @@ export function ConfirmModal(props: { 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; @@ -415,6 +457,17 @@ export function SettingsModal(props: { // 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); @@ -461,18 +514,65 @@ export function SettingsModal(props: { From 4b25697f1cea0faf5aaa8fe8ca1e416dc14614d8 Mon Sep 17 00:00:00 2001 From: fujibee Date: Wed, 15 Jul 2026 14:39:34 -0700 Subject: [PATCH 27/27] release: 1.1.8 (#410) --- .claude-plugin/plugin.json | 2 +- CHANGELOG.md | 35 ++++++++++++++++++++++++++++++++++- VERSION | 2 +- package.json | 2 +- 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index b7ab2244..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.7", + "version": "1.1.8", "author": { "name": "fujibee" }, diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b429def..383ac46a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,38 @@ 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.7] - 2026-07-12 +## [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) @@ -245,6 +276,8 @@ 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 diff --git a/VERSION b/VERSION index 2bf1ca5f..18efdb9a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.7 +1.1.8 diff --git a/package.json b/package.json index 2c2eb52c..a3e2dfba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agmsg", - "version": "1.1.7", + "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"