diff --git a/bridged/src/main/java/dev/ltms/bridged/member/ClaudeCodeLauncher.java b/bridged/src/main/java/dev/ltms/bridged/member/ClaudeCodeLauncher.java index 4bbab56..2d190d7 100644 --- a/bridged/src/main/java/dev/ltms/bridged/member/ClaudeCodeLauncher.java +++ b/bridged/src/main/java/dev/ltms/bridged/member/ClaudeCodeLauncher.java @@ -9,6 +9,10 @@ import dev.ltms.bridged.peer.Capability; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +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; @@ -230,7 +234,7 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher { // model flag so --model keeps outranking the operator's own argv. // mutableArgv: argvWithBridge may hand back the profile's own (immutable) List.of when it // has neither MCP nor a charter — session flags must be added into a list we own. - List argv = mutableArgv(argvWithBridge(cfg, spec.charter())); + List argv = mutableArgv(argvWithBridge(cfg, spec)); String agentSessionId = applySessionIdentity(argv, spec.sessionName(), spec.resumeSessionId()); return new Launch(workerEnv, argvWithModel(argv, cfg), agentSessionId); } @@ -265,13 +269,25 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher { } /** - * The launch argv, plus an inline {@code --mcp-config} when {@code worker.mcpUrl} is set and - * {@code --append-system-prompt} when the base composed a charter. Neither touches the profile's - * config; both are pure command-line flags. This inline-flag mount is Claude Code specific — - * other adapters mount MCP and instructions their own way. + * The launch argv, plus an inline {@code --mcp-config} when {@code worker.mcpUrl} is set, the + * CB-617 charter flags, and {@code --agent } when the role has an agent-definition file + * under the worker's cwd. Neither touches the profile's config; all are pure command-line flags. + * This inline-flag mount is Claude Code specific — other adapters mount MCP and instructions + * their own way. + * + *

CB-617: the role charter is operator-authored and often multi-line, so it can never be a + * single inline argv element — herdr refuses to shell-encode a multi-line argument + * ({@code invalid_agent_argument}). It is written to a temp file instead and mounted with + * {@code --append-system-prompt-file}, which this host confirms Claude Code accepts for a + * multi-line file. The reply charter is always exactly one line and demonstrably encodes, and it + * must reach a member with no repo checkout (nowhere to put a file), so it keeps its inline + * {@code --append-system-prompt} delivery. */ - private List argvWithBridge(BridgedConfig.Profile cfg, String charter) { - if (!cfg.hasMcp() && charter == null) { + private List argvWithBridge(BridgedConfig.Profile cfg, LaunchSpec spec) { + String roleCharter = nonBlank(spec.roleCharter()); + String replyCharter = nonBlank(spec.replyCharter()); + Path agentFile = agentDefinitionFile(spec.cwd(), spec.role(), ".claude", "agents"); + if (!cfg.hasMcp() && roleCharter == null && replyCharter == null && agentFile == null) { return cfg.argv(); } List argv = mutableArgv(cfg.argv()); @@ -281,13 +297,45 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher { argv.add("--mcp-config"); argv.add(mcpJson); } - if (charter != null) { + if (roleCharter != null) { + argv.add("--append-system-prompt-file"); + argv.add(writeRoleCharterFile(roleCharter).toString()); + } + if (replyCharter != null) { argv.add("--append-system-prompt"); - argv.add(charter); + argv.add(replyCharter); + } + if (agentFile != null) { + argv.add("--agent"); + argv.add(spec.role().wireName()); } return argv; } + /** {@code s}, or {@code null} when {@code s} is null/blank — the charter-presence test used above. */ + private static String nonBlank(String s) { + return (s == null || s.isBlank()) ? null : s; + } + + /** + * Write the role charter to a fresh temp file so it can be mounted with + * {@code --append-system-prompt-file} instead of riding inline in argv (CB-617). Best-effort + * cleaned via {@code deleteOnExit} — the same disposable-worker-config cleanup + * {@link OpenCodeLauncher#writeConfig} already uses for its charter file, since the process that + * reads this file (the spawned peer) outlives this JVM call and there is no spawn-scoped teardown + * hook to delete it synchronously. + */ + private static Path writeRoleCharterFile(String roleCharter) { + try { + Path file = Files.createTempFile("bridged-role-charter-", ".md"); + Files.writeString(file, roleCharter); + file.toFile().deleteOnExit(); + return file; + } catch (IOException e) { + throw new UncheckedIOException("cannot write role charter temp file", e); + } + } + /** * Pin the model on the command line as well as in {@code ANTHROPIC_MODEL} (CB-533). * diff --git a/bridged/src/main/java/dev/ltms/bridged/member/HerdrPeerLauncher.java b/bridged/src/main/java/dev/ltms/bridged/member/HerdrPeerLauncher.java index 136cdc4..1d35814 100644 --- a/bridged/src/main/java/dev/ltms/bridged/member/HerdrPeerLauncher.java +++ b/bridged/src/main/java/dev/ltms/bridged/member/HerdrPeerLauncher.java @@ -17,6 +17,8 @@ import dev.ltms.bridged.peer.SpawnRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.nio.file.Files; +import java.nio.file.Path; import java.security.SecureRandom; import java.util.ArrayList; import java.util.Collection; @@ -279,8 +281,35 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { } } - /** All per-spawn values adapters may need, including the base-composed effective charter. */ - protected record LaunchSpec(String sessionName, String resumeSessionId, MemberRole role, String charter) { + /** + * All per-spawn values adapters may need. + * + *

{@code charter} is the base-composed effective charter (role charter, then the reply + * charter, joined by a blank line) — kept for an adapter that mounts both as one blob (opencode + * writes it to a single file) and as the exact input {@link CharterReceipt#compose} fingerprints. + * {@code roleCharter} and {@code replyCharter} are the same text split back into its two parts + * (CB-617), for an adapter that must deliver them differently: the role charter is + * operator-authored and often multi-line, so it cannot travel as an inline argv element (herdr + * refuses to shell-encode a multi-line argument); the reply charter is always one line and is + * demonstrated to encode, so it may still go inline. {@code cwd} is the spawn's resolved working + * directory (CB-112), needed to look up a role's agent-definition file before the peer starts. + */ + protected record LaunchSpec(String sessionName, String resumeSessionId, MemberRole role, String charter, + String roleCharter, String replyCharter, String cwd) { + } + + /** + * The role's agent-definition file under {@code ///.md}, or {@code null} + * when absent or inapplicable (no role, no cwd, or the file does not exist) — CB-617. A member + * whose role has no such file must still spawn, so this is a lookup, never a requirement: the + * caller passes {@code --agent } only when the return value is non-null. + */ + protected static Path agentDefinitionFile(String cwd, MemberRole role, String dir1, String dir2) { + if (role == null || cwd == null || cwd.isBlank()) { + return null; + } + Path candidate = Path.of(cwd, dir1, dir2, role.wireName() + ".md"); + return Files.isRegularFile(candidate) ? candidate : null; } // --- profile surface ----------------------------------------------------------------------- @@ -387,9 +416,10 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { // start has no bridge_spawn result and no roster row, so the failure log below is the only // surface the byte count can appear on. The charter text itself is never logged. CharterReceipt receipt = CharterReceipt.compose(role, cfg.profile(), roleCharter, charter); + String cwd = resolveCwd(requestedCwd, cfg, callerCwd); try { - Launch launch = buildLaunch(cfg, new LaunchSpec(sessionName, resumeSessionId, role, charter)); - String cwd = resolveCwd(requestedCwd, cfg, callerCwd); + Launch launch = buildLaunch(cfg, new LaunchSpec(sessionName, resumeSessionId, role, charter, + roleCharter, replyCharter, cwd)); Agent agent = cfg.tabPlacement() ? spawnInTab(cfg, launch.env(), launch.argv(), cwd, role, liveFleet) : spawnAsPane(cfg, launch.env(), launch.argv(), cwd, charter); diff --git a/bridged/src/main/java/dev/ltms/bridged/member/OpenCodeLauncher.java b/bridged/src/main/java/dev/ltms/bridged/member/OpenCodeLauncher.java index e608a5f..f5549ff 100644 --- a/bridged/src/main/java/dev/ltms/bridged/member/OpenCodeLauncher.java +++ b/bridged/src/main/java/dev/ltms/bridged/member/OpenCodeLauncher.java @@ -208,8 +208,24 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { workerEnv.put("OPENCODE_CONFIG", writeConfig(cfg, spec.charter()).toString()); } applyGitToken(workerEnv, cfg); - return new Launch(workerEnv, - argvWithResume(argvWithModel(argvWithAuto(cfg), cfg), spec.resumeSessionId())); + List argv = argvWithResume(argvWithModel(argvWithAuto(cfg), cfg), spec.resumeSessionId()); + return new Launch(workerEnv, argvWithAgent(argv, spec)); + } + + /** + * The launch argv plus, when the role has an agent-definition file under the worker's cwd, + * opencode's {@code --agent } flag (CB-617). A role with no such file gets nothing added — + * the member must still spawn. + */ + private List argvWithAgent(List argv, LaunchSpec spec) { + Path agentFile = agentDefinitionFile(spec.cwd(), spec.role(), ".opencode", "agent"); + if (agentFile == null) { + return argv; + } + List withAgent = mutableArgv(argv); + withAgent.add("--agent"); + withAgent.add(spec.role().wireName()); + return withAgent; } /** diff --git a/bridged/src/test/java/dev/ltms/bridged/member/ClaudeCodeLauncherTest.java b/bridged/src/test/java/dev/ltms/bridged/member/ClaudeCodeLauncherTest.java index 068ca06..8087626 100644 --- a/bridged/src/test/java/dev/ltms/bridged/member/ClaudeCodeLauncherTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/member/ClaudeCodeLauncherTest.java @@ -15,8 +15,11 @@ 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 org.slf4j.LoggerFactory; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.List; import java.util.Map; import java.util.Set; @@ -106,10 +109,19 @@ class ClaudeCodeLauncherTest { "the operator's own args are preserved, in order, ahead of the model flag"); } + // --- CB-617: the role charter travels by file, never inline; the reply charter stays inline --- + + /** + * CB-617: herdr refuses to shell-encode a multi-line inline argv argument + * ({@code invalid_agent_argument}) — the exact failure this reproduced on profile {@code opus}. + * The role charter is operator-authored and often multi-line, so it must never appear as an argv + * element; the one-line reply charter still may. This drives the real launcher entry point + * ({@code spawn}), the same path a live spawn takes — not the argv builder in isolation. + */ @Test - void appendsTheBaseComposedRoleAndReplyCharter() { + void roleCharterTravelsByFileAndReplyCharterStaysInline() { FakeHerdr herdr = new FakeHerdr(); - String roleCharter = "You review changes."; + String roleCharter = "You review changes.\nLine two.\nLine three."; BridgedConfig.Profile cfg = new BridgedConfig.Profile( "sonnet", "http://gx00.gw:8000", null, null, "BRIDGED_WORKER_TOKEN", List.of("claude"), "tab", "bridged-workers", "w #{n}", @@ -121,11 +133,20 @@ class ClaudeCodeLauncherTest { svc.spawn(new SpawnRequest("sonnet", null, null, null, null, MemberRole.REVIEWER)); List args = spawnedArgs(herdr); - int flag = args.indexOf("--append-system-prompt"); - assertEquals(1, args.stream().filter("--append-system-prompt"::equals).count(), - "the composed charter is passed once"); - assertEquals(roleCharter + "\n\n" + HerdrPeerLauncher.REPLY_CHARTER, args.get(flag + 1), - "the role charter comes first and the reply rule comes last"); + assertTrue(args.stream().noneMatch(a -> a.contains("\n")), + "no argv element may be multi-line — herdr cannot shell-encode one: " + args); + + int fileFlag = args.indexOf("--append-system-prompt-file"); + assertTrue(fileFlag >= 0, "the role charter is mounted via --append-system-prompt-file: " + args); + assertDoesNotThrow(() -> { + String written = Files.readString(Path.of(args.get(fileFlag + 1))); + assertEquals(roleCharter, written, "the file holds exactly the role charter text"); + }, "the --append-system-prompt-file path must be a readable file"); + + int inlineFlag = args.indexOf("--append-system-prompt"); + assertTrue(inlineFlag >= 0, "the reply charter still travels inline: " + args); + assertEquals(HerdrPeerLauncher.REPLY_CHARTER, args.get(inlineFlag + 1), + "only the reply charter (one line) rides inline — the role charter never does"); } @Test @@ -135,11 +156,13 @@ class ClaudeCodeLauncherTest { svc.spawn(new SpawnRequest("sonnet", null, null, null, null, MemberRole.DEV)); - assertFalse(spawnedArgs(herdr).contains("--append-system-prompt")); + List args = spawnedArgs(herdr); + assertFalse(args.contains("--append-system-prompt")); + assertFalse(args.contains("--append-system-prompt-file")); } @Test - void profileWithoutMcpStillGetsItsRoleCharter() { + void profileWithoutMcpStillGetsItsRoleCharterAsAFileNotInline() { FakeHerdr herdr = new FakeHerdr(); String roleCharter = "You design changes."; ClaudeCodeLauncher svc = labelService(herdr, () -> fleet(Map.of("architect", roleCharter), null)); @@ -147,12 +170,52 @@ class ClaudeCodeLauncherTest { svc.spawn(new SpawnRequest("sonnet", null, null, null, null, MemberRole.ARCHITECT)); List args = spawnedArgs(herdr); - int flag = args.indexOf("--append-system-prompt"); + int flag = args.indexOf("--append-system-prompt-file"); assertTrue(flag >= 0, "a role charter does not need an MCP mount"); - assertEquals(roleCharter, args.get(flag + 1)); + assertDoesNotThrow(() -> assertEquals(roleCharter, + Files.readString(Path.of(args.get(flag + 1)))), + "the file holds the role charter"); + assertFalse(args.contains("--append-system-prompt"), "no reply charter without an MCP mount"); assertFalse(args.contains("--mcp-config")); } + // --- CB-617: --agent when the role has an agent-definition file -------------------- + + @Test + void agentFlagIsPassedWhenTheRoleAgentDefinitionFileExists(@TempDir Path cwd) throws Exception { + FakeHerdr herdr = new FakeHerdr(); + Path agentsDir = Files.createDirectories(cwd.resolve(".claude/agents")); + Files.writeString(agentsDir.resolve("reviewer.md"), "---\nname: reviewer\n---\nBe a reviewer."); + BridgedConfig.Profile cfg = new BridgedConfig.Profile( + "sonnet", "http://gx00.gw:8000", null, null, "BRIDGED_WORKER_TOKEN", + List.of("claude"), "tab", "bridged-workers", "w #{n}", null, null, null); + ClaudeCodeLauncher svc = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), + new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null); + + svc.spawn(new SpawnRequest("sonnet", cwd.toString(), null, null, null, MemberRole.REVIEWER)); + + List args = spawnedArgs(herdr); + int flag = args.indexOf("--agent"); + assertTrue(flag >= 0, "--agent is passed when the role's agent file exists: " + args); + assertEquals("reviewer", args.get(flag + 1)); + } + + @Test + void noAgentFlagWhenTheRoleAgentDefinitionFileIsAbsent(@TempDir Path cwd) { + FakeHerdr herdr = new FakeHerdr(); + BridgedConfig.Profile cfg = new BridgedConfig.Profile( + "sonnet", "http://gx00.gw:8000", null, null, "BRIDGED_WORKER_TOKEN", + List.of("claude"), "tab", "bridged-workers", "w #{n}", null, null, null); + ClaudeCodeLauncher svc = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), + new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null); + + PeerHandle handle = svc.spawn(new SpawnRequest("sonnet", cwd.toString(), null, null, null, MemberRole.REVIEWER)); + + assertNotNull(handle, "the member still spawns with no agent-definition file"); + assertFalse(spawnedArgs(herdr).contains("--agent"), + "no --agent flag when the role has no agent-definition file"); + } + private ClaudeCodeLauncher multiProfile(FakeHerdr herdr) { BridgedConfig.Profile gx10 = new BridgedConfig.Profile("gx10", "http://gx10.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", List.of("claude"), "tab", "bridged-workers", "w #{n}", null, null, null); diff --git a/bridged/src/test/java/dev/ltms/bridged/member/OpenCodeLauncherTest.java b/bridged/src/test/java/dev/ltms/bridged/member/OpenCodeLauncherTest.java index a7b5010..c1b50ec 100644 --- a/bridged/src/test/java/dev/ltms/bridged/member/OpenCodeLauncherTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/member/OpenCodeLauncherTest.java @@ -8,6 +8,7 @@ import dev.ltms.bridged.herdr.FakeHerdr; import dev.ltms.bridged.herdr.WorkspaceControl; import dev.ltms.bridged.peer.Capability; import dev.ltms.bridged.peer.CharterReceipt; +import dev.ltms.bridged.peer.MemberRole; import dev.ltms.bridged.peer.PeerHandle; import dev.ltms.bridged.peer.PeerUnreachableException; import dev.ltms.bridged.peer.SpawnRequest; @@ -208,6 +209,35 @@ class OpenCodeLauncherTest { "--auto is unconditional: a model-less worker still must never block on approval"); } + // --- CB-617: --agent when the role has an agent-definition file -------------------- + + @Test + void agentFlagIsPassedWhenTheRoleAgentDefinitionFileExists(@TempDir Path root) throws Exception { + FakeHerdr herdr = new FakeHerdr(); + Path agentsDir = Files.createDirectories(root.resolve(".opencode/agent")); + Files.writeString(agentsDir.resolve("dev.md"), "You are a dev."); + OpenCodeLauncher svc = service(herdr, root, opencodeCfg(null, null, null)); + + svc.spawn(new SpawnRequest(null, root.toString(), null, null, null, MemberRole.DEV)); + + List args = startArgs(herdr); + int flag = args.indexOf("--agent"); + assertTrue(flag >= 0, "--agent is passed when the role's agent file exists: " + args); + assertEquals("dev", args.get(flag + 1)); + } + + @Test + void noAgentFlagWhenTheRoleAgentDefinitionFileIsAbsent(@TempDir Path root) { + FakeHerdr herdr = new FakeHerdr(); + OpenCodeLauncher svc = service(herdr, root, opencodeCfg(null, null, null)); + + PeerHandle handle = svc.spawn(new SpawnRequest(null, root.toString(), null, null, null, MemberRole.DEV)); + + assertNotNull(handle, "the member still spawns with no agent-definition file"); + assertFalse(startArgs(herdr).contains("--agent"), + "no --agent flag when the role has no agent-definition file"); + } + @Test void injectsForgeTokenWhenProfileGrantsIt(@TempDir Path root) { FakeHerdr herdr = new FakeHerdr();