Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
5211fc9
fix(quickstart): document agent's allowed values to moat init
dpup Aug 11, 2026
2a6a892
feat(cli): validate moat.yaml agent against the provider registry
dpup Aug 11, 2026
1df21f4
fix(cli): let the CLI verb win over moat.yaml agent
dpup Aug 11, 2026
5e93f6a
fix(cli): restore pre-ConfigureAgent snapshot for the conflict check
dpup Aug 11, 2026
4d03cfe
fix(run): stage codex from the codex-cli dependency like every other …
dpup Aug 11, 2026
e7e5cf0
feat(run): compute the set of agents provisioned into a container
dpup Aug 11, 2026
8d1c383
feat(run): persist the joinable-agent set to run metadata
dpup Aug 11, 2026
4983ecc
fix(join): gate on provisioned agents instead of the agent string
dpup Aug 11, 2026
ce3eb4a
fix(join): resolve run names against running runs only
dpup Aug 12, 2026
8201356
refactor(run): export SortRunsByCreatedAt to de-duplicate cli helper
dpup Aug 12, 2026
469bfa9
feat(provider): add AgentRuntime for declarative agent provisioning
dpup Aug 12, 2026
ef1d965
feat(config): add agents: to provision several agents into one container
dpup Aug 12, 2026
cfff03a
feat(cli): resolve the primary agent when agents: is set
dpup Aug 12, 2026
70c0a17
feat(codex): support moat join
dpup Aug 12, 2026
3d59db1
test(run): cover missing-grant detection for expanded agent grants
dpup Aug 12, 2026
7a03aa6
feat(join): accept moat join <agent> with an inferred run
dpup Aug 12, 2026
25f1947
feat(join): infer join candidates from workspace and capability
dpup Aug 12, 2026
9f27b90
feat(join): add an interactive run picker for the shorthand
dpup Aug 12, 2026
c230ba2
fix(join): sort picker candidates and disambiguate zero-host errors
dpup Aug 12, 2026
ec0b3c9
fix(join): pick between same-named running runs instead of erroring
dpup Aug 12, 2026
dd114c1
docs: correct agent: reference and document multi-agent joins
dpup Aug 12, 2026
04421f3
test(e2e): add a dual-agent join test for agents: [claude, codex]
dpup Aug 12, 2026
aa29c8b
docs(cli): fix stale v1 join help text
dpup Aug 12, 2026
c32628e
fix(e2e): make dual-agent join gate check fatal on empty output
dpup Aug 12, 2026
a33e6f6
fix(cli): stop agents:-derived grants outranking an auto-detected cre…
dpup Aug 12, 2026
b94d8d4
fix(wt): expand agents: and resolve agent: in moat wt
dpup Aug 12, 2026
e0a4213
fix(join): point join remedy messages at the canonical agent name
dpup Aug 12, 2026
4ba906a
docs: correct agents[0] foreground claim and document --grant's overr…
dpup Aug 12, 2026
0a5da8b
docs(changelog): summarize the multi-agent join work in Unreleased
dpup Aug 12, 2026
ec93d5c
fix(cli): write agents:-derived grants back onto cfg.Grants
dpup Aug 13, 2026
24ce3fb
docs(changelog): link the multi-agent join entries to their PR
dpup Aug 13, 2026
92a9875
Merge remote-tracking branch 'origin/main' into feat/multi-agent-join-v2
dpup Aug 13, 2026
ee08d40
fix: address review feedback on multi-agent join (#454)
dpup Aug 13, 2026
cffec48
fix(cli): canonicalize agent: and validate it under --dry-run
dpup Aug 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,16 @@ Realigns the Codex integration with the current Codex CLI (0.146). Codex's own a

A second pass fixes the terminal handling that made Codex hard to use inside Moat: `ctrl+/` did not register at all, and Codex's inline rendering was corrupted because Moat overrode terminal state the agent legitimately drives.

A third pass adds multi-agent containers: `moat.yaml`'s new `agents:` list provisions several agents into one run, and `moat join` gates on what moat actually provisioned instead of an unvalidated `agent:` string, fixing joins that previously failed for any project using a project-shaped `agent:` value.

Separately, port-exposing agents no longer disappear from the routing proxy's discovery index while their containers are still running.

### Added

- **`MOAT_TTY_TRACE`** — an environment-variable equivalent of `--tty-trace`, recording a session's terminal I/O to a file for debugging TUI and input problems. The sessions worth tracing are the broken ones, where the in-session `ctrl+/ d` dump may itself be unreachable, and exporting a variable captures every subsequent run without editing each command line. See [Environment variables](https://majorcontext.com/moat/reference/environment). ([#450](https://github.com/majorcontext/moat/pull/450))
- **Remote MCP servers for Codex** — top-level `mcp:` entries are now wired into Codex, not just Claude Code. They are written to the `[mcp_servers]` table of the generated `~/.codex/config.toml` as streamable HTTP servers whose `url` points at the proxy relay, so the proxy injects the real credential exactly as it does for Claude Code. Previously `mcp:` was silently ignored for Codex runs. See [MCP servers](https://majorcontext.com/moat/guides/mcp). ([#449](https://github.com/majorcontext/moat/pull/449))
- **Multi-agent containers** — `agents: [claude, codex]` in `moat.yaml` provisions several agents into one container, each with its dependencies, credential grant, and network rules. `moat join <run> codex` then works in a container started by `moat claude`. See [Multi-agent sessions](https://majorcontext.com/moat/guides/multi-agent). ([#454](https://github.com/majorcontext/moat/pull/454))
- `moat join <agent>` infers the run from the current workspace, offering a picker when several qualify. ([#454](https://github.com/majorcontext/moat/pull/454))

### Changed

Expand All @@ -37,6 +41,12 @@ Separately, port-exposing agents no longer disappear from the routing proxy's di
- Fix `codex.sync_logs` never syncing anything — the setting was documented as writing session logs to the host, and defaulted on whenever the `openai` grant was configured, but no mount was ever created: the flag only influenced whether the Codex staging directory was built. Codex session transcripts now appear on the host at `~/.moat/codex/sessions/<workspace>-<id>/YYYY/MM/DD/rollout-*.jsonl`, in Codex's own format. ([#449](https://github.com/majorcontext/moat/pull/449))
- Fix Codex prompting to trust `/workspace` on first run — the generated config now marks the workspace trusted. ([#449](https://github.com/majorcontext/moat/pull/449))
- `codex.mcp` and `gemini.mcp` may now both declare local MCP servers in one `moat.yaml`. They previously collided on `/workspace/.mcp.json` and were rejected at config load; Codex no longer uses that file. ([#449](https://github.com/majorcontext/moat/pull/449))
- Fix `moat join` refusing to attach to containers that were running the agent — previously, join compared `moat.yaml`'s `agent:` field against a fixed list, so any other value (including the project-shaped names the reference docs and `moat init` both produced) made every join fail. Join now uses the set of agents moat actually provisioned into the container. Runs created before this change must be recreated. ([#454](https://github.com/majorcontext/moat/pull/454))
- Fix `moat join <name>` failing when several runs share a name — previously, any project setting `name:` in `moat.yaml` gave every run the same name, and a second concurrent run made join error instead of offering a choice. ([#454](https://github.com/majorcontext/moat/pull/454))
- Fix `moat join`'s extra positional arguments being silently ignored — `moat join <run> <agent> <extra>` used to accept and discard `<extra>`; it is now a usage error. `moat join` now takes one or two positional arguments (`[run] agent`), not a minimum of two. ([#454](https://github.com/majorcontext/moat/pull/454))
- Fix the `agent:` reference documentation, which described the field as a free-form identifier defaulting to `name`. It is a fixed set of agent names and defaults to the command you ran. ([#454](https://github.com/majorcontext/moat/pull/454))
- Fix `agent: openai` and `agent: google` being accepted and then ignored — the provider aliases passed validation but were never normalized to `codex` / `gemini`, so the agent-specific defaults (container memory, implied dependencies, language-server support) all silently switched off. `agent:` is now normalized to the provider name, including when it is backfilled from the first entry of `agents:`. ([#454](https://github.com/majorcontext/moat/pull/454))
- Fix `--dry-run` skipping `agent:` validation — a `moat.yaml` with an unrecognized `agent:` previewed cleanly and only warned once you started a real run, which is backwards for the flag people use to check their config. ([#454](https://github.com/majorcontext/moat/pull/454))

### Breaking

Expand Down
152 changes: 124 additions & 28 deletions cmd/moat/cli/join_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/majorcontext/moat/internal/provider"
"github.com/majorcontext/moat/internal/run"
"github.com/majorcontext/moat/internal/term"
"github.com/majorcontext/moat/internal/ui"
)

var (
Expand All @@ -26,19 +27,26 @@ var (
)

var joinCmd = &cobra.Command{
Use: "join <run> <agent> [flags]",
Use: "join [run] <agent> [flags]",
Short: "Launch another agent inside a running container",
Long: `Launch a second agent inside an already-running container, reusing its
workspace, grants, and credentials — without creating a new container.

The agent must match the one the run was started with (v1 supports same-agent
joins, e.g. joining claude into a run started by 'moat claude').
The agent must be one the run was provisioned with — not necessarily the one
it was started with. A run created from moat.yaml's 'agents:' list can be
joined as any agent in that list.

The run argument is optional: with just an agent, moat infers the run from
the running runs in the current workspace, and prompts when more than one
qualifies.

Examples:
moat join run_a1b2c3d4e5f6 claude
moat join my-feature claude --continue
moat join run_a1b2c3d4e5f6 claude -p "summarize the diff"`,
Args: cobra.MinimumNArgs(2),
moat join run_a1b2c3d4e5f6 claude -p "summarize the diff"
moat join claude # infer the run from this workspace
moat join run_a1b2c3d4e5f6 codex`,
Args: cobra.RangeArgs(1, 2),
RunE: runJoin,
}

Expand All @@ -49,17 +57,42 @@ func init() {
rootCmd.AddCommand(joinCmd)
}

// validateJoinAgent checks that the run (whose recorded agent field is runAgent)
// was created by the requested provider. agentArg is the user-typed agent name,
// used only for the error message.
func validateJoinAgent(j provider.JoinableAgent, agentArg, runAgent string) error {
if !j.IdentifiesAs(runAgent) {
return fmt.Errorf("run has no %s configuration.\n"+
"v1 join only attaches an agent the run was started with (run agent: %q).\n"+
"To run %s here, start the run with %s configured.",
agentArg, runAgent, agentArg, agentArg)
// validateJoinAgent reports whether agentArg may be launched into r.
//
// The authority is r.JoinableAgents — what moat actually provisioned. A nil set
// means the run was created before capability tracking, so we fall back to the
// legacy agent-string check; an EMPTY set is a real answer ("nothing joinable
// here") and must not fall back. That distinction is why the metadata field is
// persisted without omitempty.
//
// canonical is agentArg resolved through registry aliases (openai -> codex).
// The diagnosis half of each error keeps agentArg, the string the user typed,
// so they recognize what they asked for; the remedy half uses canonical, so a
// suggested `moat <name>` or `agents: [<name>]` names a real command/value —
// `moat openai` is not a command.
func validateJoinAgent(j provider.JoinableAgent, agentArg, canonical string, r *run.Run) error {
if r.JoinableAgents != nil {
for _, a := range r.JoinableAgents {
if a == agentArg || j.IdentifiesAs(a) {
return nil
}
}
hosted := "none"
if len(r.JoinableAgents) > 0 {
hosted = strings.Join(r.JoinableAgents, ", ")
}
return fmt.Errorf("run %s cannot host %s (provisioned agents: %s).\n"+
"Add %s to this project's moat.yaml `agents:` list and recreate the run.",
r.ID, agentArg, hosted, canonical)
}
return nil

// Pre-upgrade run: no capability set was ever recorded.
if j.IdentifiesAs(r.Agent) {
return nil
}
return fmt.Errorf("run %s was created before capability tracking and records agent %q.\n"+
"Recreate the run to join it (moat stop %s && moat %s).",
r.ID, r.Agent, r.ID, canonical)
}

// joinableAgentNames returns the sorted names of registered agents that support
Expand All @@ -75,33 +108,50 @@ func joinableAgentNames() []string {
return names
}

// parseJoinArgs interprets join's positional arguments.
//
// Two args are `<run> <agent>`, unchanged. A single arg is the AGENT — it is
// the required half, while the run is what gets inferred. When that arg is also
// a run name, the agent wins and collided is set so the caller can say so; the
// two-arg form is the escape hatch.
func parseJoinArgs(args []string, isRunName func(string) bool) (runArg, agentArg string, collided bool, err error) {
if len(args) >= 2 {
return args[0], args[1], false, nil
}
arg := args[0]
if provider.GetAgent(arg) == nil {
return "", "", false, fmt.Errorf("unknown agent %q; joinable agents: %s",
arg, strings.Join(joinableAgentNames(), ", "))
}
return "", arg, isRunName(arg), nil
}

func runJoin(cmd *cobra.Command, args []string) error {
if joinContinue && joinResume != "" {
return fmt.Errorf("--continue and --resume are mutually exclusive")
}

runArg := args[0]
agentArg := args[1]

manager, err := run.NewManager()
if err != nil {
return fmt.Errorf("creating run manager: %w", err)
}
defer manager.Close()

runID, err := resolveRunArgSingle(manager, runArg)
isRunName := func(s string) bool {
matches, rErr := manager.Resolve(s)
return rErr == nil && len(matches) > 0
}
runArg, agentArg, collided, err := parseJoinArgs(args, isRunName)
if err != nil {
return err
}

r, gErr := manager.Get(runID)
if gErr != nil {
return gErr
}
if r.GetState() != run.StateRunning {
return fmt.Errorf("run %s is not running (state: %s)", runID, r.GetState())
if collided {
ui.Warnf("%q matches both an agent and a run name; interpreting as agent.\n"+
"Use `moat join %s <agent>` to target the run.", agentArg, agentArg)
}

// The agent/provider lookup happens before run resolution: the shorthand
// path needs `joinable` to filter candidates by hosting capability.
agent := provider.GetAgent(agentArg)
if agent == nil {
return fmt.Errorf("unknown agent %q; joinable agents: %s", agentArg, strings.Join(joinableAgentNames(), ", "))
Expand All @@ -110,7 +160,53 @@ func runJoin(cmd *cobra.Command, args []string) error {
if !ok {
return fmt.Errorf("agent %q does not support join yet", agentArg)
}
if valErr := validateJoinAgent(joinable, agentArg, r.Agent); valErr != nil {
// canonical is what the user typed, resolved through registry aliases
// (openai -> codex) — agentArg itself must stay as typed for the
// membership checks below (JoinableAgents / IdentifiesAs match against
// the canonical name while agentArg may be the alias), but any remedy
// text suggesting a command must say `moat codex`, not `moat openai`.
canonical := agent.Name()

var r *run.Run
if runArg == "" {
cwd, cwdErr := os.Getwd()
if cwdErr != nil {
return fmt.Errorf("resolving working directory: %w", cwdErr)
}
allRuns := manager.List()
candidates, widened := inferJoinCandidates(allRuns, cwd, agentArg, joinable)
anyRunning := len(filterRunning(allRuns)) > 0
picked, pickErr := pickJoinRun(os.Stdin, os.Stderr, candidates, agentArg, canonical, widened,
term.IsTerminal(os.Stdin) && term.IsTerminal(os.Stderr), anyRunning)
if pickErr != nil {
return pickErr
}
r = picked
} else {
var candidates []*run.Run
r, candidates, err = resolveRunningRunArg(manager, runArg)
if err != nil {
return err
}
if r == nil {
// Several running runs share this name — nothing enforces run-name
// uniqueness, and moat.yaml's `name:` field means every run in a
// project commonly shares one. Route through the same picker the
// shorthand form uses (Task 16) rather than erroring, so the
// explicit and shorthand forms behave the same way. widened=false:
// the user named a run explicitly, so there was no workspace-widening
// search to disclose. anyRunning=true: resolveRunningRunArg only
// returns a candidate list when more than one running run matched.
picked, pickErr := pickJoinRun(os.Stdin, os.Stderr, candidates, agentArg, canonical, false,
term.IsTerminal(os.Stdin) && term.IsTerminal(os.Stderr), true)
if pickErr != nil {
return pickErr
}
r = picked
}
}

if valErr := validateJoinAgent(joinable, agentArg, canonical, r); valErr != nil {
return valErr
}

Expand All @@ -129,7 +225,7 @@ func runJoin(cmd *cobra.Command, args []string) error {
// and headless paths need an index so console output lands in logs.<N>.jsonl.
// Do NOT defer release here — we call it explicitly before exitWithExecError
// so registry cleanup runs even when the agent exits with a non-zero code.
index, release, regErr := manager.RegisterJoinedAgent(runID)
index, release, regErr := manager.RegisterJoinedAgent(r.ID)

var execErr error
// Headless (--prompt with no TTY) vs interactive.
Expand Down
Loading
Loading