diff --git a/bridged/bridged.example.yaml b/bridged/bridged.example.yaml index ab6644a..28d5b8e 100644 --- a/bridged/bridged.example.yaml +++ b/bridged/bridged.example.yaml @@ -29,6 +29,7 @@ herdrSocket: ~/.config/herdr/herdr.sock # Put `defaultMode: "auto"` in each ccs profile so the worker runs autonomously. workers: gx10: # ccs profile name (NOT a hostname) + kind: claude-code # which adapter spawns this profile (default; may omit) baseUrl: http://gx01.gw:8000 # the vLLM host this profile targets (gx00.gw / gx01.gw) model: coder placement: tab @@ -44,6 +45,19 @@ workers: tabLabel: "worker: {profile} #{n}" mcpUrl: http://127.0.0.1:8765/mcp argv: ["ccs", "ollama"] + # CB-402: a second coding-agent kind, proving the PeerLauncher SPI is provider-neutral. + # opencode is provider-agnostic and uses NONE of Claude's private seams: no ANTHROPIC_BASE_URL / + # SubscriptionGuard (so it needs no `guard` host entry), no --mcp-config / --append-system-prompt. + # The bridge MCP + reply charter mount via a generated OPENCODE_CONFIG file, and the model is a + # `provider/model` selector. Placement, tabs, cwd, and the readiness gate are shared with Claude. + # opencode-gemini: + # kind: opencode + # model: google/gemini-2.5-pro # opencode `provider/model` selector, injected as `-m` + # placement: tab + # workspace: bridged-workers + # tabLabel: "opencode: {model} #{n}" + # mcpUrl: http://127.0.0.1:8765/mcp + # argv: ["opencode"] defaultWorker: gx10 # Subscription boundary. A worker's base_url host MUST be one of these; the primary diff --git a/bridged/src/main/java/dev/ltms/bridged/Bridged.java b/bridged/src/main/java/dev/ltms/bridged/Bridged.java index 21bff47..edaa3db 100644 --- a/bridged/src/main/java/dev/ltms/bridged/Bridged.java +++ b/bridged/src/main/java/dev/ltms/bridged/Bridged.java @@ -28,11 +28,18 @@ import dev.ltms.bridged.session.SessionManager; import dev.ltms.bridged.peer.PeerLauncher; import dev.ltms.bridged.session.SessionReaper; import dev.ltms.bridged.worker.ClaudeCodeLauncher; +import dev.ltms.bridged.worker.CompositePeerLauncher; +import dev.ltms.bridged.worker.HerdrPeerLauncher; +import dev.ltms.bridged.worker.OpenCodeLauncher; import io.javalin.Javalin; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; import java.util.concurrent.Executors; /** @@ -63,9 +70,33 @@ public final class Bridged { AgentControl agents = new AgentControl(herdr); WorkspaceControl spaces = new WorkspaceControl(herdr); - PeerLauncher workers = new ClaudeCodeLauncher(agents, spaces, guard, - cfg.workerProfiles(), cfg.defaultProfile(), System::getenv, - cfg.spawnReadyTimeoutMs(), cfg.spawnReadyPollMs()); + // CB-402: one adapter per configured peer kind, fronted by a composite router. A profile's + // `kind:` selects its adapter — claude-code (the default) and opencode partition the profile + // set — and the composite dispatches each SPI call to the adapter that owns the profile/pane. + Map claudeProfiles = new LinkedHashMap<>(); + Map opencodeProfiles = new LinkedHashMap<>(); + cfg.workerProfiles().forEach((name, w) -> { + if (w.isOpenCode()) { + opencodeProfiles.put(name, w); + } else { + claudeProfiles.put(name, w); + } + }); + List adapters = new ArrayList<>(); + // The claude-code adapter is the always-present default; keep it even with no profiles (so a + // bridge configured with no workers, or opencode-only, still has a well-defined base adapter) + // unless opencode is the only kind configured. + if (!claudeProfiles.isEmpty() || opencodeProfiles.isEmpty()) { + adapters.add(new ClaudeCodeLauncher(agents, spaces, guard, + claudeProfiles, cfg.defaultProfile(), System::getenv, + cfg.spawnReadyTimeoutMs(), cfg.spawnReadyPollMs())); + } + if (!opencodeProfiles.isEmpty()) { + adapters.add(new OpenCodeLauncher(agents, spaces, + opencodeProfiles, cfg.defaultProfile(), System::getenv, + cfg.spawnReadyTimeoutMs(), cfg.spawnReadyPollMs())); + } + PeerLauncher workers = new CompositePeerLauncher(adapters, cfg.defaultProfile()); // CB-117: herdr keeps worker panes alive across a daemon restart, and their ids died with // the previous process — reap those leaked orphans now, before we start serving. workers.reapOrphanWorkers(); @@ -151,8 +182,7 @@ public final class Bridged { // Caller identity is resolved from the connection (peer PID → herdr pane), not arguments. ConnectionIdentity identity = new ConnectionIdentity( new PaneLocator(herdr), new LsofPeerPidLookup(), new LsofProcessCwdLookup()); - // Cast to ClaudeCodeLauncher: BridgeMcp is not yet migrated to PeerLauncher (Stage A scope). - BridgeMcp mcp = new BridgeMcp(messages, rendezvous, (ClaudeCodeLauncher) workers, sessions, identity, presence, + BridgeMcp mcp = new BridgeMcp(messages, workers, sessions, identity, presence, primaryRegistry); // CB-303 part 3: single ordered shutdown hook. Drain sessions first while herdr is still @@ -176,7 +206,7 @@ public final class Bridged { herdr.close(); })); - Javalin app = new BridgedApp(herdr, (ClaudeCodeLauncher) workers, sessions, messages, rendezvous, presence, mcp.servlet()).build(); + Javalin app = new BridgedApp(herdr, workers, sessions, messages, presence, mcp.servlet()).build(); app.start(cfg.bind().host(), cfg.bind().port()); log.info("bridged listening on {}:{}, herdr socket {}", cfg.bind().host(), cfg.bind().port(), socket); diff --git a/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java b/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java index 9845dad..8147f11 100644 --- a/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java +++ b/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java @@ -90,6 +90,12 @@ public record BridgedConfig( * (minimal-grant default — push over SSH stays free, PR-create is opt-in) * @param gitHostEnv name of the host env var holding the forge host (default {@code GITEA_HOST}); * injected as {@code GITEA_HOST} only when {@code gitTokenEnv} is set + * @param kind which peer launcher spawns this profile: {@code "claude-code"} (default — + * the {@link dev.ltms.bridged.worker.ClaudeCodeLauncher}) or {@code "opencode"}. + * The {@code CompositePeerLauncher} routes {@code spawn}/reap by this value, so + * each adapter drives only its own kind. Normalised to lower-case; blank ⇒ the + * default. It selects the adapter, not the transport — placement, tabs, cwd, and + * the readiness gate are kind-independent and stay in the shared base. */ @JsonIgnoreProperties(ignoreUnknown = true) public record Worker(String profile, String baseUrl, String model, @@ -97,9 +103,23 @@ public record BridgedConfig( String placement, String workspace, String tabLabel, String mcpUrl, String cwd, List parityOverlay, - String gitTokenEnv, String gitHostEnv) { + String gitTokenEnv, String gitHostEnv, + String kind) { + + /** Peer kind spawned by {@link dev.ltms.bridged.worker.ClaudeCodeLauncher} (the default). */ + public static final String KIND_CLAUDE_CODE = "claude-code"; + /** Peer kind spawned by the opencode adapter (CB-402). */ + public static final String KIND_OPENCODE = "opencode"; + public Worker { - argv = (argv == null || argv.isEmpty()) ? List.of("claude") : List.copyOf(argv); + // A claude-code worker defaults its launch command to `claude`; other kinds carry their own + // argv (e.g. `opencode`) and must not inherit the Claude binary — so only default when unset + // AND this is the claude-code kind. + String k = (kind == null || kind.isBlank()) ? KIND_CLAUDE_CODE : kind.toLowerCase(); + argv = (argv == null || argv.isEmpty()) + ? (KIND_CLAUDE_CODE.equals(k) ? List.of("claude") : List.of(k)) + : List.copyOf(argv); + kind = k; tokenEnv = (tokenEnv == null || tokenEnv.isBlank()) ? "BRIDGED_WORKER_TOKEN" : tokenEnv; placement = (placement == null || placement.isBlank()) ? "tab" : placement.toLowerCase(); workspace = (workspace == null || workspace.isBlank()) ? "bridged-workers" : workspace; @@ -122,13 +142,35 @@ public record BridgedConfig( String placement, String workspace, String tabLabel, String mcpUrl, String cwd, List parityOverlay) { this(profile, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel, - mcpUrl, cwd, parityOverlay, null, null); + mcpUrl, cwd, parityOverlay, null, null, null); + } + + /** + * Backward-compatible constructor with the CB-302 git-forge fields but no explicit peer + * {@code kind} — defaults to {@link #KIND_CLAUDE_CODE}. Keeps pre-CB-402 call sites working. + */ + public Worker(String profile, String baseUrl, String model, + String configDir, String tokenEnv, List argv, + String placement, String workspace, String tabLabel, String mcpUrl, + String cwd, List parityOverlay, String gitTokenEnv, String gitHostEnv) { + this(profile, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel, + mcpUrl, cwd, parityOverlay, gitTokenEnv, gitHostEnv, null); } /** A copy with {@code profile} set — used to default a profile to its {@code workers} key. */ public Worker withProfile(String p) { return new Worker(p, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel, - mcpUrl, cwd, parityOverlay, gitTokenEnv, gitHostEnv); + mcpUrl, cwd, parityOverlay, gitTokenEnv, gitHostEnv, kind); + } + + /** True when this profile is served by the Claude Code adapter (the default kind). */ + public boolean isClaudeCode() { + return KIND_CLAUDE_CODE.equals(kind); + } + + /** True when this profile is served by the opencode adapter (CB-402). */ + public boolean isOpenCode() { + return KIND_OPENCODE.equals(kind); } /** True when this profile's workers are granted a forge token to open their own PR (CB-302). */ @@ -203,11 +245,6 @@ public record BridgedConfig( */ @JsonIgnoreProperties(ignoreUnknown = true) public record Primary(String terminal, Integer pushReminders, Integer pushBackoffMs) { - /** Legacy constructor with just a terminal — both push knobs default. */ - public Primary(String terminal) { - this(terminal, null, null); - } - /** @return configured reminder cap, or 5 */ public int remindersOrDefault() { return pushReminders != null ? pushReminders : 5; diff --git a/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java b/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java index b7bb772..4c76580 100644 --- a/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java +++ b/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java @@ -10,7 +10,7 @@ import dev.ltms.bridged.peer.PeerUnreachableException; import dev.ltms.bridged.session.SessionManager; import dev.ltms.bridged.session.WorkerSession; import dev.ltms.bridged.session.WorktreeRequest; -import dev.ltms.bridged.worker.ClaudeCodeLauncher; +import dev.ltms.bridged.peer.PeerLauncher; import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.json.jackson3.JacksonMcpJsonMapperSupplier; @@ -35,8 +35,8 @@ import java.util.stream.Collectors; * / {@code bridge_status}; the worker calls {@code bridge_reply}. * *

Beyond delegation the primary also manages the fleet here (CB-108): {@code bridge_spawn} / - * {@code bridge_list} / {@code bridge_stop} adapt {@link ClaudeCodeLauncher} so a worker's whole lifecycle - * is driven through MCP, with the subscription boundary still enforced inside {@code ClaudeCodeLauncher}. + * {@code bridge_list} / {@code bridge_stop} drive the {@link PeerLauncher} SPI so a worker's whole + * lifecycle is managed through MCP, with each adapter's subscription boundary enforced inside it. * *

The tool logic lives in package-private static methods returning a * {@link McpSchema.CallToolResult}, so it is unit-testable without standing up the HTTP transport; @@ -60,7 +60,7 @@ public final class BridgeMcp { private final HttpServletStreamableServerTransportProvider transport; private final McpSyncServer server; - public BridgeMcp(MessageService messages, Rendezvous rendezvous, ClaudeCodeLauncher workers, + public BridgeMcp(MessageService messages, PeerLauncher workers, SessionManager sessions, ConnectionIdentity identity, WorkerPresence presence, PrimaryRegistry primaryRegistry) { McpJsonMapper json = new JacksonMcpJsonMapperSupplier().get(); @@ -373,16 +373,17 @@ public final class BridgeMcp { } /** {@code bridge_profiles}: the configured worker profiles and the default. */ - static McpSchema.CallToolResult profiles(ClaudeCodeLauncher workers) { + static McpSchema.CallToolResult profiles(PeerLauncher workers) { return text(json(Map.of( "profiles", workers.profiles(), "default", workers.defaultProfile() == null ? "" : workers.defaultProfile()))); } /** {@code bridge_list}: bridge-owned roster merged with live herdr status by paneId. */ - static McpSchema.CallToolResult listWorkers(ClaudeCodeLauncher workers, SessionManager sessions) { + static McpSchema.CallToolResult listWorkers(PeerLauncher workers, SessionManager sessions) { try { Map live = workers.list().stream() + .map(Agent.class::cast) .filter(a -> a.paneId() != null) .collect(Collectors.toMap(Agent::paneId, Function.identity(), (_, b) -> b)); List> out = sessions.roster().stream() diff --git a/bridged/src/main/java/dev/ltms/bridged/rest/BridgedApp.java b/bridged/src/main/java/dev/ltms/bridged/rest/BridgedApp.java index b2f66a2..d71899b 100644 --- a/bridged/src/main/java/dev/ltms/bridged/rest/BridgedApp.java +++ b/bridged/src/main/java/dev/ltms/bridged/rest/BridgedApp.java @@ -9,11 +9,10 @@ import dev.ltms.bridged.herdr.HerdrException; import dev.ltms.bridged.inject.WorkerPresence; import dev.ltms.bridged.peer.PeerUnreachableException; import dev.ltms.bridged.msg.MessageService; -import dev.ltms.bridged.msg.Rendezvous; import dev.ltms.bridged.session.SessionManager; import dev.ltms.bridged.session.WorkerSession; import dev.ltms.bridged.session.WorktreeRequest; -import dev.ltms.bridged.worker.ClaudeCodeLauncher; +import dev.ltms.bridged.peer.PeerLauncher; import io.javalin.Javalin; import io.javalin.http.Context; import jakarta.servlet.http.HttpServlet; @@ -45,22 +44,20 @@ public final class BridgedApp { private static final long MAX_ASK_TIMEOUT_MS = 115_000; private final HerdrClient herdr; - private final ClaudeCodeLauncher workers; + private final PeerLauncher workers; private final SessionManager sessions; // CB-301: authoritative session registry private final MessageService messages; - private final Rendezvous rendezvous; private final WorkerPresence presence; // CB-113: which workers are MCP-connected (available) private final HttpServlet mcpServlet; // MCP Streamable-HTTP endpoint, mounted at /mcp (nullable) private final ObjectMapper mapper = new ObjectMapper(); - public BridgedApp(HerdrClient herdr, ClaudeCodeLauncher workers, SessionManager sessions, - MessageService messages, Rendezvous rendezvous, WorkerPresence presence, + public BridgedApp(HerdrClient herdr, PeerLauncher workers, SessionManager sessions, + MessageService messages, WorkerPresence presence, HttpServlet mcpServlet) { this.herdr = herdr; this.workers = workers; this.sessions = sessions; this.messages = messages; - this.rendezvous = rendezvous; this.presence = presence; this.mcpServlet = mcpServlet; } @@ -125,12 +122,14 @@ public final class BridgedApp { /** Discovery: every agent herdr tracks, keyed by its Claude session UUID. */ private void agents(Context ctx) { - ctx.status(200).json(Map.of("agents", workers.list().stream().map(BridgedApp::view).toList())); + ctx.status(200).json(Map.of("agents", + workers.list().stream().map(Agent.class::cast).map(BridgedApp::view).toList())); } /** CB-304: bridge-owned roster merged with live herdr status by paneId. */ private void listWorkers(Context ctx) { Map live = workers.list().stream() + .map(Agent.class::cast) .filter(a -> a.paneId() != null) .collect(Collectors.toMap(Agent::paneId, Function.identity(), (_, b) -> b)); List> out = sessions.roster().stream() diff --git a/bridged/src/main/java/dev/ltms/bridged/worker/CompositePeerLauncher.java b/bridged/src/main/java/dev/ltms/bridged/worker/CompositePeerLauncher.java new file mode 100644 index 0000000..45922a9 --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/worker/CompositePeerLauncher.java @@ -0,0 +1,160 @@ +package dev.ltms.bridged.worker; + +import dev.ltms.bridged.herdr.Agent; +import dev.ltms.bridged.peer.Capability; +import dev.ltms.bridged.peer.PeerHandle; +import dev.ltms.bridged.peer.PeerLauncher; +import dev.ltms.bridged.peer.SpawnRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * The {@link PeerLauncher} the core actually holds when more than one adapter is configured — a thin + * router in front of one {@link HerdrPeerLauncher} per peer {@code kind} (Claude Code, opencode, …). + * It owns no transport of its own; it dispatches each SPI call to the delegate that owns the profile + * involved, and fans the fleet-wide queries (list/reap/caps/profiles) across all delegates. + * + *

Routing rules: + *

    + *
  • By profile — {@link #spawn}, {@link #effectiveCwd}, {@link #parityOverlay} + * resolve the profile (a null/blank name → the global {@link #defaultProfile}) and delegate to + * the single adapter that declares it. Profiles partition cleanly across adapters: the + * constructor rejects a name claimed by two.
  • + *
  • By pane id — {@link #stop} routes to the adapter that spawned that pane + * (recorded at spawn time). A pane the composite never spawned (only real for a caller that + * hand-rolls an id) falls back to the first delegate; teardown is pane-id addressed and + * tab cleanup is single-occupant guarded, so it is safe either way.
  • + *
  • Fleet-wide — {@link #reapOrphanWorkers} and {@link #capabilities} fan out + * and combine. {@link #list} is deduplicated by pane id because every herdr-backed delegate + * shares one herdr connection and so reports the same global agent set.
  • + *
+ */ +public final class CompositePeerLauncher implements PeerLauncher { + + private static final Logger log = LoggerFactory.getLogger(CompositePeerLauncher.class); + + private final List delegates; + private final Map byProfile; + private final String defaultProfile; + + /** paneId → the delegate that spawned it, so {@link #stop} tears down through the right adapter. */ + private final Map spawnedBy = new ConcurrentHashMap<>(); + + /** + * @param delegates one adapter per configured peer kind; must be non-empty and declare + * disjoint profile-name sets + * @param defaultProfile the profile a no-argument spawn resolves to (may be null) + * @throws IllegalArgumentException if {@code delegates} is empty or two adapters claim one profile + */ + public CompositePeerLauncher(List delegates, String defaultProfile) { + if (delegates.isEmpty()) { + throw new IllegalArgumentException("at least one peer adapter must be configured"); + } + this.delegates = List.copyOf(delegates); + this.defaultProfile = defaultProfile; + Map index = new LinkedHashMap<>(); + for (HerdrPeerLauncher d : this.delegates) { + for (String profile : d.profiles()) { + HerdrPeerLauncher prev = index.putIfAbsent(profile, d); + if (prev != null) { + throw new IllegalArgumentException( + "worker profile '" + profile + "' is claimed by two peer adapters"); + } + } + } + this.byProfile = Map.copyOf(index); + } + + /** The adapter owning {@code profileName} (null/blank → the default). Throws on an unknown profile. */ + private HerdrPeerLauncher route(String profileName) { + String resolved = (profileName == null || profileName.isBlank()) ? defaultProfile : profileName; + if (resolved == null) { + // No profile and no default configured — hand to the first delegate so it raises the + // same "no default" error it would on its own; keeps the SPI contract single-sourced. + return delegates.getFirst(); + } + HerdrPeerLauncher d = byProfile.get(resolved); + if (d == null) { + throw new IllegalArgumentException("unknown worker profile: " + resolved); + } + return d; + } + + @Override + public PeerHandle spawn(SpawnRequest req) { + HerdrPeerLauncher d = route(req.profileName()); + PeerHandle handle = d.spawn(req); + spawnedBy.put(handle.id(), d); + return handle; + } + + @Override + public String effectiveCwd(SpawnRequest req) { + return route(req.profileName()).effectiveCwd(req); + } + + @Override + public List parityOverlay(String profileName) { + return route(profileName).parityOverlay(profileName); + } + + @Override + public void stop(String id) { + HerdrPeerLauncher d = spawnedBy.remove(id); + if (d == null) { + log.debug("stop({}) — no recorded owner, routing to the first adapter (pane-addressed)", id); + d = delegates.getFirst(); + } + d.stop(id); + } + + @Override + public Set profiles() { + return byProfile.keySet(); + } + + @Override + public String defaultProfile() { + return defaultProfile; + } + + /** Every herdr agent, deduplicated by pane id (all delegates share one herdr and list globally). */ + @Override + public List list() { + Map byPane = new LinkedHashMap<>(); + for (HerdrPeerLauncher d : delegates) { + for (Agent a : d.list()) { + if (a.paneId() != null) { + byPane.putIfAbsent(a.paneId(), a); + } + } + } + return List.copyOf(byPane.values()); + } + + @Override + public int reapOrphanWorkers() { + int reaped = 0; + for (HerdrPeerLauncher d : delegates) { + reaped += d.reapOrphanWorkers(); + } + return reaped; + } + + /** The union of every adapter's capabilities — a capability any adapter offers, the fleet offers. */ + @Override + public Set capabilities() { + EnumSet caps = EnumSet.noneOf(Capability.class); + for (HerdrPeerLauncher d : delegates) { + caps.addAll(d.capabilities()); + } + return Set.copyOf(caps); + } +} diff --git a/bridged/src/main/java/dev/ltms/bridged/worker/OpenCodeLauncher.java b/bridged/src/main/java/dev/ltms/bridged/worker/OpenCodeLauncher.java new file mode 100644 index 0000000..48b534e --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/worker/OpenCodeLauncher.java @@ -0,0 +1,233 @@ +package dev.ltms.bridged.worker; + +import dev.ltms.bridged.config.BridgedConfig; +import dev.ltms.bridged.herdr.Agent; +import dev.ltms.bridged.herdr.AgentControl; +import dev.ltms.bridged.herdr.WorkspaceControl; +import dev.ltms.bridged.peer.Capability; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.EnumSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.function.LongSupplier; + +/** + * The {@link HerdrPeerLauncher} adapter for opencode — an open-source, + * provider-agnostic terminal coding agent. Its whole reason for existing is to prove the + * {@code PeerLauncher} SPI is genuinely provider-neutral: opencode shares none of Claude Code's + * private launch seams, yet reuses every line of shared transport in the base (tab/pane placement, + * the CB-306 readiness gate, unique naming + CB-117 reap, teardown, listing, cwd). + * + *

The divergences from {@link ClaudeCodeLauncher}, all confined to {@link #buildLaunch}: + *

    + *
  • No subscription boundary. opencode carries no {@code ANTHROPIC_BASE_URL} + * and there is no {@link dev.ltms.bridged.guard.SubscriptionGuard} — the guard is a + * Claude-private concern, not part of the SPI. opencode reads the operator's own provider + * credentials from its global {@code auth.json}; the bridge injects none.
  • + *
  • File-based MCP mount + instructions. opencode has no inline + * {@code --mcp-config}/{@code --append-system-prompt}. Instead the bridge writes an ephemeral + * {@code opencode.json} that declares the bridge as a {@code remote} MCP server and lists a + * reply-charter file under {@code instructions}, then points the worker at it with + * {@code OPENCODE_CONFIG}. This is the one place the launcher touches disk — Claude never did.
  • + *
  • Model as a flag. the {@code provider/model} selector is passed as + * {@code -m}, not an env var.
  • + *
  • {@code opencode} name prefix so reap matches {@code opencode-*} panes and + * never another adapter's.
  • + *
+ */ +public final class OpenCodeLauncher extends HerdrPeerLauncher { + + /** Label prefix for this adapter's herdr agent names (drives naming + orphan reap). */ + private static final String NAME_PREFIX = "opencode"; + + /** + * Standing instruction written to the charter file and mounted via the config's + * {@code instructions} so the worker returns its result through {@code bridge_reply}. Kept on + * disk (not a launch flag) because opencode's {@code instructions} takes file paths, not inline + * text — the file is regenerated per spawn and never touches the worker's own profile. + */ + static final String REPLY_CHARTER = + "You are an off-subscription worker in the claude-bridge fleet, running under opencode. " + + "Every message you receive arrives through the bridge, and the ONLY channel back to the " + + "sender is the bridge_reply MCP tool. Text you write in your terminal is NOT sent " + + "anywhere — the sender cannot see your screen, so an in-terminal answer is silently " + + "discarded. Therefore you MUST end EVERY turn by calling bridge_reply with `content` set " + + "to your complete response. This holds for every message without exception — tasks, " + + "questions, clarifications, acknowledgements, and ordinary back-and-forth conversation. " + + "Call bridge_reply exactly once, as the final action of your turn, with your full answer " + + "in `content`; never wait for confirmation first. If you end a turn without calling " + + "bridge_reply, the sender receives nothing and the exchange stalls."; + + /** Root under which per-spawn opencode config dirs are created (injectable for tests). */ + private final Path configRoot; + + /** + * Production constructor — disables the spawn-ready gate ({@code spawnReadyTimeoutMs == 0}) so it + * matches the legacy non-blocking spawn semantics. Config dirs are created under the JVM temp dir. + */ + public OpenCodeLauncher(AgentControl agents, WorkspaceControl spaces, + Map profiles, String defaultProfile, + Function env) { + this(agents, spaces, profiles, defaultProfile, env, 0, + System::currentTimeMillis, () -> sleepUninterruptibly(300), + defaultConfigRoot()); + } + + /** + * Production constructor with the spawn-ready gate enabled. Polls {@code agents.status()} until + * the pane reports an injectable state or {@code spawnReadyTimeoutMs} elapses. + */ + public OpenCodeLauncher(AgentControl agents, WorkspaceControl spaces, + Map profiles, String defaultProfile, + Function env, + long spawnReadyTimeoutMs, long spawnReadyPollMs) { + this(agents, spaces, profiles, defaultProfile, env, spawnReadyTimeoutMs, + System::currentTimeMillis, () -> sleepUninterruptibly(spawnReadyPollMs), + defaultConfigRoot()); + } + + /** + * Full testability constructor. Every injectable collaborator is explicit so unit tests supply a + * fake clock ({@code nowMillis}), poll-loop wait ({@code sleeper}), and a temp {@code configRoot} + * they can inspect the generated {@code opencode.json}/charter under. + * + * @param agents herdr agent control (start, status, close) + * @param spaces workspace / tab control (ensure, create, close) + * @param profiles configured worker profiles + * @param defaultProfile profile a no-argument spawn uses (nullable) + * @param env host env lookup (injectable for tests) + * @param spawnReadyTimeoutMs max ms to wait for injectable state (0 disables the gate) + * @param nowMillis monotonic clock source (e.g. {@code System::currentTimeMillis}) + * @param sleeper sleep/wait hook (encodes the poll interval; never called when the + * gate is disabled) + * @param configRoot existing directory under which per-spawn config dirs are created + */ + public OpenCodeLauncher(AgentControl agents, WorkspaceControl spaces, + Map profiles, String defaultProfile, + Function env, + long spawnReadyTimeoutMs, + LongSupplier nowMillis, Runnable sleeper, Path configRoot) { + super(NAME_PREFIX, agents, spaces, profiles, defaultProfile, env, + spawnReadyTimeoutMs, nowMillis, sleeper); + this.configRoot = configRoot; + } + + private static Path defaultConfigRoot() { + return Path.of(System.getProperty("java.io.tmpdir")); + } + + /** + * {@inheritDoc} + * + *

Builds the opencode launch: no {@code ANTHROPIC_*} and no guard (opencode reads its own + * provider credentials); when the profile mounts the bridge MCP, generate an ephemeral + * {@code opencode.json} (remote MCP server + reply-charter instructions) and point the worker at + * it via {@code OPENCODE_CONFIG}; carry the parity-neutral git-forge grant; and select the model + * with {@code -m}. + */ + @Override + protected Launch buildLaunch(BridgedConfig.Worker cfg) { + Map workerEnv = newEnv(); + if (cfg.hasMcp()) { + workerEnv.put("OPENCODE_CONFIG", writeConfig(cfg).toString()); + } + applyGitToken(workerEnv, cfg); + return new Launch(workerEnv, argvWithModel(cfg)); + } + + /** The launch argv plus, when a model is configured, the opencode {@code -m provider/model} flag. */ + private List argvWithModel(BridgedConfig.Worker cfg) { + List argv = mutableArgv(cfg.argv()); + if (cfg.model() != null && !cfg.model().isBlank()) { + argv.add("-m"); + argv.add(cfg.model()); + } + return argv; + } + + /** + * Write an ephemeral {@code opencode.json} (and the reply-charter file it references) into a + * fresh per-spawn directory under {@link #configRoot}, and return the config file's path for + * {@code OPENCODE_CONFIG}. The dir is unique per spawn so concurrent workers never race on it; + * it is best-effort cleaned on JVM exit (worker config is disposable — regenerated every spawn). + */ + private Path writeConfig(BridgedConfig.Worker cfg) { + try { + Path dir = Files.createTempDirectory(configRoot, "bridged-opencode-"); + dir.toFile().deleteOnExit(); + + Path charter = dir.resolve("reply-charter.md"); + Files.writeString(charter, REPLY_CHARTER); + charter.toFile().deleteOnExit(); + + String json = "{\n" + + " \"$schema\": \"https://opencode.ai/config.json\",\n" + + " \"mcp\": { \"bridge\": { \"type\": \"remote\", \"url\": \"" + + jsonEscape(cfg.mcpUrl()) + "\", \"enabled\": true } },\n" + + " \"instructions\": [\"" + jsonEscape(charter.toAbsolutePath().toString()) + "\"]\n" + + "}\n"; + Path cfgFile = dir.resolve("opencode.json"); + Files.writeString(cfgFile, json); + cfgFile.toFile().deleteOnExit(); + return cfgFile; + } catch (IOException e) { + throw new UncheckedIOException( + "cannot write opencode config for profile " + cfg.profile(), e); + } + } + + /** Minimal JSON string escaping for the two interpolated values (a URL and an absolute path). */ + private static String jsonEscape(String s) { + return s.replace("\\", "\\\\").replace("\"", "\\\""); + } + + // --- Agent-returning convenience spawns (used by callers/tests that want the herdr Agent) --- + + /** Spawn a worker for the default profile in the resolved default cwd. */ + public Agent spawn() { + return spawnInternal(null, null, null); + } + + /** Spawn a worker for a named profile (null → default) in the resolved default cwd. */ + public Agent spawn(String profileName) { + return spawnInternal(profileName, null, null); + } + + /** Spawn a worker for a named profile with an explicit requested/caller cwd (CB-112). */ + public Agent spawn(String profileName, String requestedCwd, String callerCwd) { + return spawnInternal(profileName, requestedCwd, callerCwd); + } + + // --- capabilities -------------------------------------------------------------------------- + + @Override + public Set capabilities() { + Set caps = EnumSet.of(Capability.MID_TURN_ASK, Capability.WORKTREE, Capability.ORPHAN_REAP); + if (hasGitTokenProfile()) { + caps.add(Capability.SELF_PR); + } + return Set.copyOf(caps); + } + + /** Whether any configured profile opts into a git-forge token (required for {@link Capability#SELF_PR}). */ + private boolean hasGitTokenProfile() { + return profileConfigs().stream().anyMatch(BridgedConfig.Worker::hasGitToken); + } + + // --- CB-117 reap predicate (opencode prefix), kept for direct unit testing ----------------- + + /** + * Whether {@code name} is an opencode bridge worker started by a different process than + * {@code currentNonce}. A thin {@code opencode}-prefix binding of + * {@link HerdrPeerLauncher#isForeignWorker(String, String, String)}. + */ + static boolean isForeignWorker(String name, String currentNonce) { + return HerdrPeerLauncher.isForeignWorker(NAME_PREFIX, name, currentNonce); + } +} diff --git a/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java b/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java index 2ec3f90..f5f4742 100644 --- a/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java @@ -160,4 +160,77 @@ class BridgedConfigTest { assertTrue(cfg.primary().terminal() == null || cfg.primary().terminal().isBlank(), "a blank terminal in yaml should be treated as absent — null or empty are equivalent"); } + + // --- CB-402: peer kind discriminator ------------------------------------------------------- + + @Test + void workerKindDefaultsToClaudeCodeWhenOmitted(@TempDir Path dir) throws Exception { + Path f = dir.resolve("kind-absent.yaml"); + Files.writeString(f, """ + workers: + gx10: + baseUrl: http://gx10.gw:8000 + argv: ["ccs", "gx10"] + """); + + BridgedConfig cfg = BridgedConfig.load(f); + assertEquals(BridgedConfig.Worker.KIND_CLAUDE_CODE, cfg.workerProfiles().get("gx10").kind(), + "a worker with no kind: is a claude-code worker (backward compatible)"); + } + + @Test + void opencodeKindIsNormalizedToLowerCase(@TempDir Path dir) throws Exception { + Path f = dir.resolve("kind-opencode.yaml"); + Files.writeString(f, """ + workers: + gemini: + kind: OpenCode + model: google/gemini-2.5-pro + argv: ["opencode"] + """); + + BridgedConfig cfg = BridgedConfig.load(f); + assertEquals(BridgedConfig.Worker.KIND_OPENCODE, cfg.workerProfiles().get("gemini").kind(), + "kind is normalised to lower-case so YAML casing does not matter"); + } + + @Test + void kindPredicatesReflectTheResolvedKind(@TempDir Path dir) throws Exception { + Path f = dir.resolve("kind-predicates.yaml"); + Files.writeString(f, """ + workers: + claude: + baseUrl: http://gx10.gw:8000 + gemini: + kind: opencode + model: google/gemini-2.5-pro + """); + + BridgedConfig cfg = BridgedConfig.load(f); + BridgedConfig.Worker claude = cfg.workerProfiles().get("claude"); + BridgedConfig.Worker gemini = cfg.workerProfiles().get("gemini"); + assertTrue(claude.isClaudeCode(), "the default-kind worker is claude-code"); + assertFalse(claude.isOpenCode(), "a claude-code worker is not opencode"); + assertTrue(gemini.isOpenCode(), "the kind: opencode worker is opencode"); + assertFalse(gemini.isClaudeCode(), "an opencode worker is not claude-code"); + } + + @Test + void argvDefaultsToTheKindBinaryWhenUnset(@TempDir Path dir) throws Exception { + Path f = dir.resolve("kind-argv.yaml"); + Files.writeString(f, """ + workers: + claude: + baseUrl: http://gx10.gw:8000 + gemini: + kind: opencode + model: google/gemini-2.5-pro + """); + + BridgedConfig cfg = BridgedConfig.load(f); + assertEquals(java.util.List.of("claude"), cfg.workerProfiles().get("claude").argv(), + "a claude-code worker with no argv defaults to the claude binary"); + assertEquals(java.util.List.of("opencode"), cfg.workerProfiles().get("gemini").argv(), + "an opencode worker with no argv defaults to the opencode binary, never claude"); + } } diff --git a/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppTest.java b/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppTest.java index 8cc0562..d546ca4 100644 --- a/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppTest.java @@ -75,7 +75,7 @@ class BridgedAppTest { poller.start(); Rendezvous rendezvous = new Rendezvous(); MessageService messages = new MessageService(agents, injector, rendezvous); - app = new BridgedApp(herdr, workers, sessions, messages, rendezvous, this.presence, null) + app = new BridgedApp(herdr, workers, sessions, messages, this.presence, null) .build().start("127.0.0.1", 0); return app.port(); } diff --git a/bridged/src/test/java/dev/ltms/bridged/worker/CompositePeerLauncherTest.java b/bridged/src/test/java/dev/ltms/bridged/worker/CompositePeerLauncherTest.java new file mode 100644 index 0000000..dfc7227 --- /dev/null +++ b/bridged/src/test/java/dev/ltms/bridged/worker/CompositePeerLauncherTest.java @@ -0,0 +1,158 @@ +package dev.ltms.bridged.worker; + +import dev.ltms.bridged.config.BridgedConfig; +import dev.ltms.bridged.guard.SubscriptionGuard; +import dev.ltms.bridged.herdr.AgentControl; +import dev.ltms.bridged.herdr.FakeHerdr; +import dev.ltms.bridged.herdr.WorkspaceControl; +import dev.ltms.bridged.peer.PeerHandle; +import dev.ltms.bridged.peer.PeerLauncher; +import dev.ltms.bridged.peer.SpawnRequest; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * The composite router: profile → owning adapter for spawn/cwd/parity, pane id → owner for stop, + * and fleet-wide union/dedup for list/reap/caps/profiles. Exercised through two real adapters — + * claude-code + opencode — over one FakeHerdr, so each call is observed reaching the right adapter + * (the started herdr agent name carries that adapter's {@code claude-}/{@code opencode-} prefix). + */ +class CompositePeerLauncherTest { + + private ClaudeCodeLauncher claudeAdapter(FakeHerdr herdr) { + // 12-arg back-compat Worker ctor → kind defaults to claude-code. + BridgedConfig.Worker claude = new BridgedConfig.Worker("claude", "http://gx00.gw:8000", "coder", + null, "BRIDGED_WORKER_TOKEN", List.of("claude"), "tab", "bridged-workers", "w #{n}", + null, null, null); + return new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), + new SubscriptionGuard(Set.of("gx00.gw")), Map.of("claude", claude), "claude", _ -> null); + } + + private OpenCodeLauncher opencodeAdapter(FakeHerdr herdr) { + BridgedConfig.Worker gemini = new BridgedConfig.Worker("gemini", null, "google/gemini-2.5-pro", + null, "BRIDGED_WORKER_TOKEN", List.of("opencode"), "tab", "bridged-workers", "w #{n}", + null, null, null, "GITEA_ACCESS_TOKEN", null, BridgedConfig.Worker.KIND_OPENCODE); + return new OpenCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), + Map.of("gemini", gemini), "gemini", _ -> "tok"); + } + + private CompositePeerLauncher composite(FakeHerdr herdr) { + return new CompositePeerLauncher( + List.of(claudeAdapter(herdr), opencodeAdapter(herdr)), "claude"); + } + + @SuppressWarnings("unchecked") + private static String startedName(FakeHerdr herdr) { + return (String) ((Map) herdr.lastCall("agent.start").params()).get("name"); + } + + @Test + void spawnRoutesEachProfileToItsOwningAdapter() { + FakeHerdr herdr = new FakeHerdr(); + PeerLauncher composite = composite(herdr); + + composite.spawn(new SpawnRequest("gemini", null, null)); + assertTrue(startedName(herdr).startsWith("opencode-"), + "the gemini profile is spawned by the opencode adapter: " + startedName(herdr)); + + composite.spawn(new SpawnRequest("claude", null, null)); + assertTrue(startedName(herdr).startsWith("claude-"), + "the claude profile is spawned by the claude-code adapter: " + startedName(herdr)); + } + + @Test + void nullProfileResolvesTheDefaultAndRoutesToItsOwner() { + FakeHerdr herdr = new FakeHerdr(); + composite(herdr).spawn(new SpawnRequest(null, null, null)); + assertTrue(startedName(herdr).startsWith("claude-"), + "a no-profile spawn resolves the default (claude) and routes to its adapter"); + } + + @Test + void unknownProfileIsRejected() { + FakeHerdr herdr = new FakeHerdr(); + PeerLauncher composite = composite(herdr); + assertThrows(IllegalArgumentException.class, + () -> composite.spawn(new SpawnRequest("nope", null, null)), + "a profile no adapter declares is an error"); + } + + @Test + void profilesAndDefaultAreExposedAcrossAdapters() { + FakeHerdr herdr = new FakeHerdr(); + PeerLauncher composite = composite(herdr); + assertEquals(Set.of("claude", "gemini"), composite.profiles(), + "profiles are the union of every adapter's profiles"); + assertEquals("claude", composite.defaultProfile()); + } + + @Test + void capabilitiesAreTheUnionOfEveryAdapter() { + FakeHerdr herdr = new FakeHerdr(); + ClaudeCodeLauncher claude = claudeAdapter(herdr); + OpenCodeLauncher opencode = opencodeAdapter(herdr); + PeerLauncher composite = new CompositePeerLauncher(List.of(claude, opencode), "claude"); + + assertTrue(composite.capabilities().containsAll(claude.capabilities()), + "the fleet offers every claude-code capability"); + assertTrue(composite.capabilities().containsAll(opencode.capabilities()), + "the fleet offers every opencode capability (incl. SELF_PR from its git-token profile)"); + } + + @Test + void listIsDeduplicatedByPaneIdAcrossAdaptersSharingHerdr() { + FakeHerdr herdr = new FakeHerdr(); + PeerLauncher composite = composite(herdr); + // Both adapters wrap the same herdr, so each list() returns the same global agent set; + // the composite must return each pane once, not once per adapter. + assertEquals(1, composite.list().size(), + "the single herdr-tracked pane appears once, not duplicated per adapter"); + } + + @Test + void reapSumsAcrossAdaptersAndEachAdapterReapsOnlyItsOwnPrefix() { + // One foreign opencode orphan + one foreign claude orphan, from a prior daemon (different nonce). + FakeHerdr herdr = new FakeHerdr() + .withAgent("opencode-gemini-ffffff-1", "term_o", "wQ:pO", "wQ:tO") + .withAgent("claude-claude-eeeeee-1", "term_c", "wQ:pC", "wQ:tC"); + PeerLauncher composite = composite(herdr); + assertEquals(2, composite.reapOrphanWorkers(), + "both orphans are reaped — one by each adapter, summed by the composite"); + } + + @Test + void stopTearsDownAPaneSpawnedThroughTheComposite() { + FakeHerdr herdr = new FakeHerdr(); + PeerLauncher composite = composite(herdr); + PeerHandle handle = composite.spawn(new SpawnRequest("gemini", null, null)); + + composite.stop(handle.id()); + assertTrue(herdr.calls.stream() + .anyMatch(c -> c.method().equals("pane.close") + && handle.id().equals(((Map) c.params()).get("pane_id"))), + "stop routes to the spawning adapter and closes that worker's pane"); + } + + @Test + void constructorRejectsAProfileClaimedByTwoAdapters() { + FakeHerdr herdr = new FakeHerdr(); + // Two opencode adapters both declaring "gemini" — a profile-name collision. + OpenCodeLauncher a = opencodeAdapter(herdr); + OpenCodeLauncher b = opencodeAdapter(herdr); + assertThrows(IllegalArgumentException.class, + () -> new CompositePeerLauncher(List.of(a, b), "gemini"), + "a profile two adapters both claim is a configuration error"); + } + + @Test + void constructorRejectsAnEmptyAdapterList() { + assertThrows(IllegalArgumentException.class, + () -> new CompositePeerLauncher(List.of(), "claude"), + "at least one adapter must be configured"); + } +} diff --git a/bridged/src/test/java/dev/ltms/bridged/worker/OpenCodeLauncherTest.java b/bridged/src/test/java/dev/ltms/bridged/worker/OpenCodeLauncherTest.java new file mode 100644 index 0000000..d6768b9 --- /dev/null +++ b/bridged/src/test/java/dev/ltms/bridged/worker/OpenCodeLauncherTest.java @@ -0,0 +1,179 @@ +package dev.ltms.bridged.worker; + +import dev.ltms.bridged.config.BridgedConfig; +import dev.ltms.bridged.herdr.AgentControl; +import dev.ltms.bridged.herdr.FakeHerdr; +import dev.ltms.bridged.herdr.WorkspaceControl; +import dev.ltms.bridged.peer.Capability; +import dev.ltms.bridged.peer.PeerHandle; +import dev.ltms.bridged.peer.PeerUnreachableException; +import dev.ltms.bridged.peer.SpawnRequest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * The opencode adapter's launch build: a file-based MCP mount + reply-charter instructions (no + * inline flags, no {@code ANTHROPIC_*}, no guard), the {@code -m} model flag, and the shared base + * transport (naming, reap, readiness gate) proving the {@link HerdrPeerLauncher} SPI is neutral. + */ +class OpenCodeLauncherTest { + + private static BridgedConfig.Worker opencodeCfg(String model, String mcpUrl, String gitTokenEnv) { + return new BridgedConfig.Worker("gemini", null, model, null, "BRIDGED_WORKER_TOKEN", + List.of("opencode"), "tab", "bridged-workers", "opencode: {model} #{n}", mcpUrl, + null, null, gitTokenEnv, null, BridgedConfig.Worker.KIND_OPENCODE); + } + + /** Gate-disabled launcher whose per-spawn config dirs land under an inspectable temp root. */ + private OpenCodeLauncher service(FakeHerdr herdr, Path configRoot, BridgedConfig.Worker cfg) { + return new OpenCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), + Map.of(cfg.profile(), cfg), cfg.profile(), k -> "GITEA_ACCESS_TOKEN".equals(k) ? "tok" : null, + 0, System::currentTimeMillis, () -> { }, configRoot); + } + + @SuppressWarnings("unchecked") + private static Map lastStart(FakeHerdr herdr) { + return (Map) herdr.lastCall("agent.start").params(); + } + + @SuppressWarnings("unchecked") + private static Map startEnv(FakeHerdr herdr) { + return (Map) lastStart(herdr).get("env"); + } + + @SuppressWarnings("unchecked") + private static List startArgv(FakeHerdr herdr) { + return (List) lastStart(herdr).get("argv"); + } + + @Test + void writesRemoteMcpConfigAndCharterInstructionsWhenMcpUrlSet(@TempDir Path root) throws Exception { + FakeHerdr herdr = new FakeHerdr(); + service(herdr, root, opencodeCfg("google/gemini-2.5-pro", "http://127.0.0.1:8765/mcp", null)) + .spawn(); + + Map env = startEnv(herdr); + assertNull(env.get("ANTHROPIC_BASE_URL"), "opencode carries no ANTHROPIC_* / subscription boundary"); + String cfgPath = env.get("OPENCODE_CONFIG"); + assertNotNull(cfgPath, "OPENCODE_CONFIG points the worker at the generated config file"); + assertTrue(Path.of(cfgPath).startsWith(root), "config file is generated under the injected root"); + + String json = Files.readString(Path.of(cfgPath)); + assertTrue(json.contains("\"type\": \"remote\""), "bridge is mounted as a remote MCP server"); + assertTrue(json.contains("http://127.0.0.1:8765/mcp"), "the profile's bridge MCP url is present"); + assertTrue(json.contains("\"instructions\""), "the reply charter is mounted via instructions"); + + // The instructions entry is a real file path holding the reply charter. + Path charter = Path.of(cfgPath).resolveSibling("reply-charter.md"); + assertTrue(Files.exists(charter), "the charter file the config references was written"); + assertTrue(Files.readString(charter).contains("bridge_reply"), + "the charter instructs the worker to answer via bridge_reply"); + } + + @Test + void noConfigFileWhenMcpUrlAbsent(@TempDir Path root) { + FakeHerdr herdr = new FakeHerdr(); + service(herdr, root, opencodeCfg("google/gemini-2.5-pro", null, null)).spawn(); + + assertNull(startEnv(herdr).get("OPENCODE_CONFIG"), + "no bridge MCP url → no config file and no OPENCODE_CONFIG"); + } + + @Test + void passesTheModelAsDashMFlag(@TempDir Path root) { + FakeHerdr herdr = new FakeHerdr(); + service(herdr, root, opencodeCfg("google/gemini-2.5-pro", null, null)).spawn(); + + List argv = startArgv(herdr); + assertEquals("opencode", argv.getFirst(), "base opencode command preserved first"); + int m = argv.indexOf("-m"); + assertTrue(m >= 0, "model is selected with -m"); + assertEquals("google/gemini-2.5-pro", argv.get(m + 1), "the provider/model selector follows -m"); + } + + @Test + void noModelFlagWhenModelBlank(@TempDir Path root) { + FakeHerdr herdr = new FakeHerdr(); + service(herdr, root, opencodeCfg(null, null, null)).spawn(); + assertEquals(List.of("opencode"), startArgv(herdr), "no model → argv is the bare opencode command"); + } + + @Test + void injectsForgeTokenWhenProfileGrantsIt(@TempDir Path root) { + FakeHerdr herdr = new FakeHerdr(); + service(herdr, root, opencodeCfg(null, null, "GITEA_ACCESS_TOKEN")).spawn(); + assertEquals("tok", startEnv(herdr).get("GITEA_TOKEN"), + "a git-token profile gets the peer-neutral GITEA_TOKEN grant, same as Claude"); + } + + @Test + void capabilitiesDeclareOrphanReapAndMcpAskAndConditionalSelfPr(@TempDir Path root) { + FakeHerdr herdr = new FakeHerdr(); + assertEquals(java.util.Set.of(Capability.MID_TURN_ASK, Capability.WORKTREE, Capability.ORPHAN_REAP), + service(herdr, root, opencodeCfg(null, null, null)).capabilities(), + "no git token → no SELF_PR"); + assertTrue(service(herdr, root, opencodeCfg(null, null, "GITEA_ACCESS_TOKEN")) + .capabilities().contains(Capability.SELF_PR), + "a git-token profile adds SELF_PR"); + } + + @Test + void foreignWorkerMatchesOpencodePrefixButNotClaude() { + String nonce = "abc123"; + assertTrue(OpenCodeLauncher.isForeignWorker("opencode-gemini-def456-1", nonce), + "an opencode pane from another process is foreign"); + assertFalse(OpenCodeLauncher.isForeignWorker("opencode-gemini-" + nonce + "-1", nonce), + "our own opencode pane (same nonce) is not foreign"); + assertFalse(OpenCodeLauncher.isForeignWorker("claude-ltms-local-def456-1", nonce), + "a claude pane is never reaped by the opencode adapter"); + } + + @Test + void productionConstructorsWireThroughToTheBase() { + FakeHerdr herdr = new FakeHerdr(); + BridgedConfig.Worker cfg = opencodeCfg(null, null, null); + // 5-arg (gate disabled) and 7-arg (gate enabled) production constructors both expose the profile. + OpenCodeLauncher disabled = new OpenCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), + Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null); + OpenCodeLauncher gated = new OpenCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), + Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null, 5000, 100); + assertEquals(java.util.Set.of("gemini"), disabled.profiles()); + assertEquals("gemini", gated.defaultProfile()); + } + + @Test + void spawnGateThrowsPeerUnreachableWhenNeverInjectable(@TempDir Path root) { + FakeHerdr herdr = new FakeHerdr(); + herdr.agentStatus("unknown"); // never injectable + long[] clock = {0}; + OpenCodeLauncher svc = new OpenCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), + Map.of("gemini", opencodeCfg(null, null, null)), "gemini", _ -> null, + 1000, () -> clock[0], () -> clock[0] += 50, root); + + PeerUnreachableException ex = assertThrows(PeerUnreachableException.class, + () -> svc.spawn(new SpawnRequest(null, null, null))); + assertTrue(clock[0] >= 1000, "the fake clock advanced past the timeout: " + clock[0]); + long closes = herdr.calls.stream() + .filter(c -> c.method().equals("pane.close")) + .filter(c -> "w9:pW_1".equals(((Map) c.params()).get("pane_id"))) + .count(); + assertEquals(1, closes, "the worker pane was reaped on timeout (no orphan)"); + assertNotNull(ex.getMessage()); + } + + @Test + void spawnReturnsHandleWhenGateDisabled(@TempDir Path root) { + FakeHerdr herdr = new FakeHerdr(); + PeerHandle handle = service(herdr, root, opencodeCfg(null, null, null)) + .spawn(new SpawnRequest(null, null, null)); + assertNotNull(handle, "spawn returns a handle when the gate is disabled"); + assertFalse(herdr.called("agent.get"), "no polling when the gate is disabled"); + } +}