Repository navigation
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Three permissions for app add-ons,
team:join,messages:sendandagents: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-addonmember type, #1860).app/AGMSG_CORE_REFis bumped to v1.7.1 in this PR's last commit; the bundled core is the tagged release.How it is bound
to,body,type,name,folder,model; anything else is ignored (tests sendfrom,team,cmd,args,permission_mode).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.foldergoes through the same resolution asfs.*(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.chess-2); a seat or another add-on's member is never written to.claimedrecord (names are also checked against non-failed journal entries).join.sh, the per-name confirm, thejoinedrecord 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 secondteam.joinpane waits for the first one's outcome. Cost: the window between the name check andjoin.shis 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'steam_idso a rename does not orphan them, and are settled at app start.addon-agent-open); only team, member name, type and model cross. The pane passes its journal key topty_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:
routeMessage) used by the listener, with tests for each case; the old behaviour fails three of them.inbox.shandsend.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>/scriptsform, so no home folder path appears. This text is typed into every pane the app feeds, not only into agents an add-on opened.HERDR_*,TMUX,TMUX_PANEandORCA_TERMINAL_HANDLEare 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.jsonand<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)
agents.typesteam.joinapi.shx2,join.shx1 = 3 (retry: 0)messages.sendsend.shx1 = 1 (was 2: the member pre-check is gone; core refuses a non-member and the app maps it tobad_args)agents.spawnapi.shx2,join.shx1; then the agent CLI in one PTY andpsx1 on macOS and Linux for its start time (none on Windows: read in-process). Before: plus windowapi.shx1 anddelivery.shx1 (the window now builds the member from the event and caches the delivery mode per type and options for the app run, sodelivery.shruns once per type, not per agent)agents.listpsx1 perrunning/orphanedentry,api.shx1 perclaimedentryrun_scriptsetsAGMSG_SELF_NAME=offfor 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.spawnis 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.types2-8 ms;team.join0.69-0.75 s (retry 0 ms);messages.send0.31-0.42 s (was 1.1-1.8 s beforeAGMSG_SELF_NAME=off); a send to a non-member refused in about 100 ms;agents.spawn0.9-2.8 s without a window. The ops are asynchronous to the add-on.Stops and waits
not_grantedtypenot spawnable, name invalid, model invalid,folderoutside the grant or deniedfschecksbad_args/outside_root/denied_path, before any side effectjoin.shwith another type or folderconflict, nothing is adopted; the same name, type and folder is accepted as undetectableorphaned,unknown) is not reopenedaddon_agent_reopen)agents.spawnwaits up to 15 s for the terminal to reportunknown, and a late report moves it torunning.failedonly for definite causes (a pane of that name is open, no such member,pty_spawnerror)Checked
Mac (Darwin 25.5.0), head of this PR at the time of writing is in the PR's last commit:
cargo test --manifest-path src-tauri/Cargo.toml472 pass, 1 ignored (the real-scripts test below) (25 new inaddons::agents, plus panes, conf, the shared install corpus); frontendtsc --noEmitclean,vitest472 pass..github/scripts/check-*.shandcheck-private-names.mjspass on this head.join.sh,send.sh,api.shon 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 asapp-addon, joined again with no second join, a taken name becamechess-2,whitebecamewhite-2on a later run, a send reached the app user, a send to a non-member was refused bysend.shitself in about 100 ms (no member list is read first; only a missing recipient becomesbad_args, a missing sender keeps the script's own text and isnot_available), the spawned member was registered with its type and the default folder.portable-ptycrate and spawn path aspty_spawn, not the app): afterkill -9of 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: anopeningentry becomesunknownafter a restart (OPENING_RECOVERS_TO_JOINED = false), also on Windows where the app has no job object..., 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
joinedafter the restart), S7 (release start-up check, 5 of 5), and the real-home guard.agents.spawnreturnedrunningin 92 s:join.sh79 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 tounknown). The time is core's: on this machinejoin.shtakes 47-53 s alone (5 runs),send.sh3.7-9.8 s,api.sh1.2-2.2 s,bash -c true0.12-0.28 s; started external programs (xtrace):api.sh14,send.sh36,join.sh131. That is core and Git Bash fork cost, not the app. The app launches 3 programs forteam.join, 1 formessages.send, 3 foragents.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,
GetProcessTimeson 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 asjoined(notorphaned/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. AlsoSTILL_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.rssets onlyprojects[<~/agmsg-agents>].hasTrustDialogAccepted = truein$CLAUDE_CONFIG_DIR/.claude.json, else~/.claude.json, and only when the add-on declaresagents: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
addon-agent-opento a real terminal),pty_spawnreporting the pid, and the check that the add-on frame cannot reach the app's IPC need the GUI; the Mac screen is locked.