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/config/BridgedConfig.java b/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java
index 9845dad..4267d7e 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,25 @@ 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's workers are granted a forge token to open their own PR (CB-302). */
@@ -203,11 +235,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/test/java/dev/ltms/bridged/config/BridgedConfigTest.java b/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java
index 2ec3f90..4d3d61d 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,56 @@ 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 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");
+ }
}