Skip to content

App add-ons: join a team, send messages and start agents - #1861

Open
fujibee wants to merge 38 commits into
mainfrom
addon-agent-powers
Open

fujibee wants to merge 38 commits into
mainfrom
addon-agent-powers

Conversation

@fujibee

@fujibee fujibee commented Nov 2, 2026 •

Copy link
Copy Markdown
Owner

What

Three permissions for app add-ons, team:join, messages:send and agents:spawn, and the broker ops behind them: team.join, messages.send, agents.types, agents.spawn, agents.list. The add-on UI (consent text, settings, sidebar, locales) and the chess sample are separate work on top of this branch.

Needs core v1.7.1 (the app-addon member type, #1860). app/AGMSG_CORE_REF is bumped to v1.7.1 in this PR's last commit; the bundled core is the tagged release.

How it is bound

  • Team, sender, the type's executable and options, the permission mode, the boot prompt come from the pane's identity and grants, never from the frame. The frame supplies to, body, type, name, folder, model; anything else is ignored (tests send from, team, cmd, args, permission_mode).
  • Names are checked with core's rules (agmsg_validate_agent_name) before any folder, path or prompt is made; a test runs the Rust rules and the shell function on the same names.
  • folder goes through the same resolution as fs.* (relative form, canonicalize, root containment, denylist) and must already exist. The default ~/agmsg-agents/<name> is created, canonicalized again and must stay under ~/agmsg-agents.
  • A taken name gets a free one (chess-2); a seat or another add-on's member is never written to.
  • Lock coverage: one app-wide lock per team (keyed by team id) covers only the name decision and the journal claimed record (names are also checked against non-failed journal entries). join.sh, the per-name confirm, the joined record and the terminal opening run outside it, so two add-on agents join at the same time. Core's registry lock serialises the writes to the team config. A second team.join pane waits for the first one's outcome. Cost: the window between the name check and join.sh is longer, so an outside join of the same name can land in it; an add-on member is detected through its claim folder, for a spawned agent, an outside join of the same name, type and folder cannot be told apart from ours and is accepted. Entries live in a journal (claimed, joined, opening, running, failed, orphaned, unknown), keyed by the team's team_id so a rename does not orphan them, and are settled at app start.
  • The window opens the terminal with the existing add-agent code (addon-agent-open); only team, member name, type and model cross. The pane passes its journal key to pty_spawn, which records the pid.

How the app feeds an agent it opened

An agent opened by an add-on has no delivery of its own (its folder has no hooks, so the delivery mode is off), and the app's own input path is its way to hear: the app types a one-line notice into its terminal. Three details of that path are part of this PR:

  • The pane is chosen by the message's team. A message for an agent is typed into the pane held by a window of the message's own team, not by the team that is selected in the sidebar. Before, anything for a team other than the selected one was dropped, so the players of a game started in its own team did not hear from the add-on unless the user was looking at that team; and a same-named pane of the selected team could have been chosen. The room feed still follows the selected team. A native pane (one with its own delivery) is not fed. This is one function (routeMessage) used by the listener, with tests for each case; the old behaviour fails three of them.
  • The notice says how to read and where to answer. It names this install's inbox.sh and send.sh (the install's own name, so a renamed install works), the team, the agent's own name and the sender, and says an answer written in the terminal chat is not delivered. It uses the ~/.agents/skills/<install>/scripts form, so no home folder path appears. This text is typed into every pane the app feeds, not only into agents an add-on opened.
  • The outer terminal's identity is not passed to an app terminal. HERDR_*, TMUX, TMUX_PANE and ORCA_TERMINAL_HANDLE are removed from the environment of every terminal the app starts (an agent added by hand included). When the app was started from a herdr or tmux pane, the child inherited that pane's identity and agmsg in the child tried to name or rename the outer pane.

No process is launched for any of this, and nothing new stops or refuses.

Where it keeps state

<agmsg base>/addons/agents-journal.json and <agmsg base>/addons/addon-members/<claim id>/ (one folder per add-on member, so a registration can be recognised as ours). Add-ons cannot read the base (denylist).

Process launches (per op)

op launches
agents.types 0
team.join api.sh x2, join.sh x1 = 3 (retry: 0)
messages.send send.sh x1 = 1 (was 2: the member pre-check is gone; core refuses a non-member and the app maps it to bad_args)
agents.spawn api.sh x2, join.sh x1; then the agent CLI in one PTY and ps x1 on macOS and Linux for its start time (none on Windows: read in-process). Before: plus window api.sh x1 and delivery.sh x1 (the window now builds the member from the event and caches the delivery mode per type and options for the app run, so delivery.sh runs once per type, not per agent)
agents.list 0
app start (recovery) ps x1 per running/orphaned entry, api.sh x1 per claimed entry

run_script sets AGMSG_SELF_NAME=off for every script the app runs: core's self-naming asked the launching terminal (about 1.9 s per send from a herdr pane against 0.31 s) and wrote a role-session record that bound the add-on member to that pane. This also applies to the app user's own send and join from the app, which now take the 0.6.0 path (name given explicitly).

Nothing on a timer, nothing per message received. The wait inside agents.spawn is an in-process poll of the journal every 25 ms, no launch.

Measured on a Mac with the real scripts (throwaway base, 3 runs of the integration test real_scripts_roundtrip, --ignored): agents.types 2-8 ms; team.join 0.69-0.75 s (retry 0 ms); messages.send 0.31-0.42 s (was 1.1-1.8 s before AGMSG_SELF_NAME=off); a send to a non-member refused in about 100 ms; agents.spawn 0.9-2.8 s without a window. The ops are asynchronous to the add-on.

Stops and waits

Item Origin Effect
No cap on messages product decision none
Spawn approved once per add-on product decision asks once, not a stop
A revoked power: the next op is not_granted existing grant model existing behaviour on new ops; agents already started keep running
type not spawnable, name invalid, model invalid, folder outside the grant or denied core rules and existing fs checks bad_args / outside_root / denied_path, before any side effect
A taken name product decision not a refusal: a free name is used and returned
A join raced by an outside join.sh with another type or folder product decision (detect and show) the op returns conflict, nothing is adopted; the same name, type and folder is accepted as undetectable
After a crash, an agent that may be running (orphaned, unknown) is not reopened product decision shown with its pid; the user chooses (addon_agent_reopen)
agents.spawn waits up to 15 s for the terminal to report product decision waits, then hands to the user: the entry becomes unknown, and a late report moves it to running. failed only for definite causes (a pane of that name is open, no such member, pty_spawn error)
Uninstall leaves the member; panes closing leave agents running product decision none

Checked

Mac (Darwin 25.5.0), head of this PR at the time of writing is in the PR's last commit:

  • Rust: cargo test --manifest-path src-tauri/Cargo.toml 472 pass, 1 ignored (the real-scripts test below) (25 new in addons::agents, plus panes, conf, the shared install corpus); frontend tsc --noEmit clean, vitest 472 pass.
  • Gates: the four .github/scripts/check-*.sh and check-private-names.mjs pass on this head.
  • Real scripts: the ignored integration test drives the broker over the real join.sh, send.sh, api.sh on a throwaway base (AGMSG_APP_BASE, config, storage and codex paths in a temp folder, HOME untouched), with the scripts of Add the app-addon member type (data only) #1860: joined as app-addon, joined again with no second join, a taken name became chess-2, white became white-2 on a later run, a send reached the app user, a send to a non-member was refused by send.sh itself in about 100 ms (no member list is read first; only a missing recipient becomes bad_args, a missing sender keeps the script's own text and is not_available), the spawned member was registered with its type and the default folder.
  • Child process outliving the app, with a stand-in harness (same portable-pty crate and spawn path as pty_spawn, not the app): after kill -9 of the process that holds the PTY, a plain CLI died and a CLI that ignores SIGHUP survived with its grandchild (re-parented to launchd). So a PTY child can outlive a killed app: an opening entry becomes unknown after a restart (OPENING_RECOVERS_TO_JOINED = false), also on Windows where the app has no job object.
  • Isolation at the broker level (unit tests): the frame cannot name another team or sender, a revoked power refuses the next op, a reloaded frame gets no answer, retries of join/spawn do nothing twice (a send is documented as not de-duplicated), folder escapes through .., absolute paths, a symlink out of the grant and a symlinked default folder.

Windows (win-test, build run 41777067598, head 3eea453 plus the measurement branch)

65 PASS, 0 FAIL: all add-on isolation steps, S4 (team.join, messages.send, agents.spawn, the folder escape and bad name refusals), S5 (the app is closed with an agent running: the child ends with it and the entry is joined after the restart), S7 (release start-up check, 5 of 5), and the real-home guard.

agents.spawn returned running in 92 s: join.sh 79 s, waiting for the terminal 10.6 s (inside the 15 s wait), reading the process start time 0 ms (in-process; it was a PowerShell start of about 5 s before, which pushed the wait to unknown). The time is core's: on this machine join.sh takes 47-53 s alone (5 runs), send.sh 3.7-9.8 s, api.sh 1.2-2.2 s, bash -c true 0.12-0.28 s; started external programs (xtrace): api.sh 14, send.sh 36, join.sh 131. That is core and Git Bash fork cost, not the app. The app launches 3 programs for team.join, 1 for messages.send, 3 for agents.spawn (on Windows the start time no longer starts one).

Start-time token on Windows: the token is now the process creation time (100 ns ticks, GetProcessTimes on a query-limited handle). Where a journal entry holds the old PowerShell ISO token, recovery sees a token that does not match and settles the entry as joined (not orphaned/unknown), so a later retry could open a new terminal. This is accepted only because the old form was never in a released build (no production journal has it); it is not a migration. Also STILL_ACTIVE (259) can be a real exit code, so "an exited pid always has no token" is not guaranteed; a handle-signalled check is a possible follow-up.

Windows findings: a canonical path carries \\?\ and is stripped (plain_path_str) before it reaches scripts, prompts or the trust file. Child process outliving the app, on Windows: the stand-in died with the app in one run. One start-up timeout in 11 release starts seen earlier (issue #1882), cause undetermined; the latest run is 5 of 5.

Folder trust

addons/trust.rs sets only projects[<~/agmsg-agents>].hasTrustDialogAccepted = true in $CLAUDE_CONFIG_DIR/.claude.json, else ~/.claude.json, and only when the add-on declares agents:spawn (addon_trust_agents_folder). Basis: the permissions doc says a folder outside a repository is covered for any subdirectory, apart from a git repository nested inside it. Keys and order are kept (preserve_order), temp file plus rename, permissions kept, a missing file is never created, the file is re-read after the rename and the write retried up to 3 times. Residuals: a concurrent write by Claude Code can still drop the change between read and rename (re-read catches it); the retry path is untested; the Windows project-key format is unverified.

Not checked yet

  • The running app: the window path (addon-agent-open to a real terminal), pty_spawn reporting the pid, and the check that the add-on frame cannot reach the app's IPC need the GUI; the Mac screen is locked.
  • The chess sample belongs to the UI branch.

Types only: the shapes an add-on sees for joining its team, sending a message,
listing spawnable types, spawning an agent and listing the agents it started,
with the agent states a pane can show. The backend follows in later commits.
Three permissions, team:join, messages:send and agents:spawn, and the broker ops
behind them: team.join, messages.send, agents.types, agents.spawn, agents.list.

The team, the sender, the type's executable and options come from the pane's
identity and grants; the frame supplies only to, body, type, name, folder and
model. Names are checked with the core rules before any folder is made, a folder
is resolved like fs.* and must already exist, and a taken name gets a free one.
Joins take one app-wide lock per team and go through a persisted journal
(claimed, joined, opening, running, failed, orphaned, unknown) that is settled at
app start; an agent that may still be running is shown, not reopened.

addon.conf gains the three permissions and a member key; the author check and the
shared install corpus say the same.
The window listens for the open event, takes the member as agmsg has it and
starts the pane with the same code as add agent, plus the type's model flag when
a model was chosen. The pane passes its journal key to pty_spawn so the backend
records the process and learns when it ends.
The journal is keyed by the team's stable id (team_id in its config) and keeps the
name only for display, so a team rename does not orphan the member or the agents.

agents.spawn waits for the window to report the process; a window that cannot open
the pane (a pane of that name is already open, no such member, the process did not
start) or says nothing within 15 s leaves the entry failed with the reason, never
opening. The user can open a failed, orphaned or unknown agent again.

The three team powers can be remembered and taken back like the other grants:
saved-grant checks and revoke handle team:join, messages:send and agents:spawn.
…arget

A terminal that has not reported within 15 s may still start, so the entry is
unknown and the user chooses; a late report moves it to running with its pid.
Failed stays for definite causes.

messages.send takes a member name only: core's send.sh has no broadcast target, and
the real-scripts run shows it refuses one. An ignored integration test drives the
broker over the real scripts on a throwaway base.
…led app

S4 drives a measurement pane that holds team:join, messages:send and
agents:spawn: the broker answers, a real terminal pane opens for the spawned
agent (a stand-in CLI found first on PATH), forged fields are ignored. S5 kills
the app with the agent running and reports whether the agent process survives,
then restarts the app and checks the journal entry the recovery leaves.
RealWorld asked the window to open a terminal through AppHandle::emit, so any test
that built a RealWorld pulled the window runtime into the test binary, which then
failed to start on Windows with STATUS_ENTRYPOINT_NOT_FOUND. It now holds a closure
that init_global sets; nothing the tests reach names the runtime.
…or it

When the user ticks the box at the agents:spawn grant, the app sets
projects[<~/agmsg-agents>].hasTrustDialogAccepted = true in the Claude Code config the
agents will read ($CLAUDE_CONFIG_DIR/.claude.json, else ~/.claude.json) and nothing
else: the file is round-tripped with every other key and its order kept, written
beside the original and renamed over it, read again afterwards (repeated up to three
times if another program rewrote it), and never created when missing. Claude Code
documents that one acceptance outside a repository covers the subdirectories and that
hasTrustDialogAccepted can be set by hand (code.claude.com/docs/en/permissions.md).
The home folder itself is never named. Commands: addon_trust_agents_folder (called from
the saved consent; the add-on must declare agents:spawn) and addon_agents_folder_trust
(trusted, not trusted, config not found).

Also: plain Windows paths (no verbatim prefix) for the agents' folders, the per-phase
timing of the debug trace, and two tests that failed on Windows only (a folder suffix
compared with a forward slash; the shell-validator comparison ran the WSL bash and now
runs on Unix only).
… time every broker op

A Windows run logged the case's own START report after its NAV_case_start and before the
guard's record, and the window closed on it, so a refused navigation went unseen. The window
now ends at the START of another pane. The powers driver reports the time of each op.
The app is not a seat, but core's self-naming ran for its sends and joins and asked the
terminal the app was started from. From a herdr pane that cost about 1.9 s per send
(median of 10; 0.31 s with the naming off) and wrote a role record binding the sending
member to that pane. run_script now sets AGMSG_SELF_NAME=off for every script. Started
from the Finder the time is unchanged (about 0.35 s). This also covers the app user's
own send and join.
Two tests still compared with the verbatim form canonicalize returns on Windows; the app
now names the plain form.
The guard logged that it cancelled a frame navigation but not the rule that refused it, so
a release-build startup check that timed out on a slow machine could not be explained from
its log. One more line per refusal names the rule, a fixed phrase and never an address.
…check

S6 times a bare bash, api.sh, send.sh and join.sh five times each from the same Git Bash and
counts the external programs each starts. S7 starts the release build five times and records
how often the startup check passes and why a refusal happened.
The open event now carries the member as the backend just joined it (name, type and
folder), so the window no longer asks agmsg for the member list again: one api.sh run
fewer per agent. The delivery mode of an add-on's agent is asked of agmsg once per type
and spawn options for the run of the app, instead of once per agent: one delivery.sh run
fewer for the second and later agents of a type (the two chess players). An agent added by
hand is always asked.
…prefix assignment

SHELLOPTS is readonly in the shell that runs the script, so setting it as a prefix assignment
failed and the count printed 0. env sets it in the child.
send.sh already refuses a recipient that is not registered, and the app never passes
--force, so the broker no longer runs api.sh to check the recipient before each send. That
refusal is reported to the add-on as bad_args with the reason; any other failure of the
script stays not_available. One script run fewer per send.
Two spawns into one team waited for each other's terminal, because the lock was held until the
terminal had reported (about 80 s each on Windows). The lock now covers the name decision, the
journal claim, join.sh, the check after it and the joined record, and is released before the
terminal is opened. Taking an entry from joined to opening is done under the lock, so a retry
that arrives while a terminal is opening gets the entry in flight and never a second terminal.
The lock is keyed by the team's stable id.
The team lock now covers only the name decision and the journal claim. join.sh runs outside it:
core's own registry lock serialises the writes to the team config, and the names are already
distinct, so two spawns into one team run their join.sh at the same time. A second team.join from
another pane waits for the first one's outcome instead of answering half done. The name check
now happens at claim time, so the window in which an outside join of the same name could slip in
is longer; an add-on member keeps its unique claim folder (detectable), a spawned agent keeps the
accepted residual of the same name, type and folder. The check after the join stays per name.
The saved-grant store only accepted messages:read and files:read, so saving
one of the three team powers failed with 'invalid grant': the user was asked
again on every open and Settings could not list or revoke it. Accept the three
powers (scope: the team name, as the save check already requires) and cover
save, read-back, revoke and re-save through the real store.
…is missing

Only a missing recipient is the add-on's mistake (bad_args). A sender that was
removed from the team from outside makes send.sh fail with the same wording but
the role 'from'; that error now reaches the add-on unchanged instead of being
reported as a bad recipient. The fake world checks both, with a test for each.
The PowerShell call that produced the start token took about 5 s on the
Windows test machine and sat inside the 15 s wait of agents.spawn, which then
ended as 'unknown'. Read the creation time with GetProcessTimes instead (no
program is started). The measurement driver now follows an 'unknown' spawn
through agents.list until the agent is running before it needs its pid.
…tity out of app terminals

A message for an agent whose pane belongs to a team other than the selected one
was dropped before it reached stdin-inject, so a game's players (started in
their own team) never heard from the add-on unless the user happened to look at
that team. The room feed still follows the selected team; injection now follows
the team of the window that holds the pane.

Terminals in the app also inherited HERDR_*, TMUX, TMUX_PANE and
ORCA_TERMINAL_HANDLE from the terminal the app was started from, so agmsg in the
child could act on that outer pane. They are removed from the child's
environment.
The notice typed into an agent whose pane the app feeds only said to run the
skill. An agent that did not know a reply has to be sent wrote it in its own
chat. The notice now names this install's inbox.sh and send.sh with the team,
the agent's own name and the sender, and says a chat reply is not delivered.
The overlap of two joins is now counted inside the fake join instead of timed
against a 950 ms limit, and the fixture waits up to 5 s for a terminal report
instead of 600 ms, so a slow runner cannot turn a normal open into 'unknown'.
The choice of the pane a message is typed into (by the team of the window that
holds it, never by the selected team, never a native pane) is now one pure
function used by the listener, with tests: another team's pane is reached while
the room feed stays on the selected team, a same-named pane of the selected team
is not, and a native pane is not fed.
… Claude Code session variables

A notice typed while the agent was mid-turn was lost. The app now holds it per pane and types it when the pane reports idle (a fresh pane waits for its first idle). A blocked pane keeps its notices and shows how many are waiting. A pane with no readable state gets them after 30 s, with a visible note that no ready signal was seen.

Terminals started by the app no longer inherit the CLAUDECODE / CLAUDE_CODE_CHILD_SESSION / session id / messaging socket and token / execpath variables of the shell the app was launched from; CLAUDE_CONFIG_DIR and other user configuration pass through.
…per pane

The hold now covers only the window until a pane's first idle, bounded to 30 s, after which notices are typed as they arrive. Held notices are typed as one line per pane, only one write per pane is in flight at a time, a rejected write puts the notices back, and the agent-state is tracked synchronously. CLAUDE_CODE_ENTRYPOINT is also dropped from app terminals.
…ly an unknown state at 180 s

A pane that is working through its startup turn ends the wait by itself, so it is no longer cut short. A pane whose state is unknown or never reported gets its notices after 180 s with the visible note. The sidebar reads 'starting, N waiting' while notices are held.
…able inject

The flush that holds, batches and serialises app-typed notices now lives in noticeFlusher.ts with the inject call, clock and timer passed in. Tests cover two held notices becoming one ordered submission, a notice arriving during the in-flight and settle window going out after it, a rejected write keeping the notices in order with no delivered banner, the cap for a pane that never reports a state, an uncapped wait while working, and a closed pane dropping its notices.
… back off on failure, resolve after the whole write, cap startup at 10 min

pty_inject now resolves after both the text and the Enter are written, and holds a per-pane lock for the whole transaction. A pane leaves its startup hold only after a notice was actually typed or it was seen idle, so a rejected fallback does not skip the gate and a successful retry keeps the without-ready-signal note. Failed writes keep the notices, report the reason once and retry with a delay that doubles from 1 s to 30 s; closing the pane ends the retries. A pane working or blocked through its startup waits at most 10 minutes, an unknown one 180 s.
The injection is now one helper used by the command: under the pane lock it writes the text, waits, then writes Enter, and any write or flush error ends it with that error instead of a success. Tests drive the helper with failing writers and with two concurrent injections.
The write-and-flush step is its own function, and tests drive it with a terminal whose writes succeed but whose flush fails, for the text and for the Enter.
A message that finds no pane to type into now says why in the app's log (no pane of that name, the pane has its own delivery, or it is not in a window of the message's team). Held, typed, failed and dropped notices are logged too, and the watcher says when it opens a store after startup and starts at its current end.
…t after a join or send

A team's store file can appear with its first message. The watcher found it on the next rescan and began at its current end, so that first message was never emitted. A store first seen after startup is now read from its start, skipping rows older than this app run, and a join or send through the app asks the watcher to look at once. Stores present at startup still begin at their end. The app_log command from the previous commit is granted to the main window.
…m feed and pane notices to one per message

A store that was not present at the watcher's first look (and was not created before the app started) is read from its start; stores present then begin at their end; no row is filtered by its own timestamp, which an imported message keeps from its origin. The room feed merges live messages by id with the history page, and a message is typed into a pane once even when a store is found again.
…e first look keeps its first row

A history page finishing after the team changed (or a stale reload) is dropped, with its has-more flag. A store file created since the app started is read from its start at the first look as well as later; where the system gives no creation time, the first look treats the file as there at startup and a later look treats it as new, and the log says which. The per-pane record of typed message ids keeps the last 500.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant