CB-617 Unit A: role charter via file, not argv
herdr refused to shell-encode a multi-line inline --append-system-prompt argument (invalid_agent_argument), which broke any Claude Code profile with a configured multi-line fleet.charters.<role>. Split delivery: the role charter now writes to a temp file and mounts via --append-system-prompt-file; the one-line REPLY_CHARTER keeps its inline --append-system-prompt delivery, since it must reach a member with no repo checkout. Also pass --agent <role> when a role's agent-definition file exists under the worker's cwd (.claude/agents/<role>.md for claude-code, .opencode/agent/<role>.md for opencode); absent either file, nothing extra is added and the member still spawns. The base's LaunchSpec now carries roleCharter/replyCharter/cwd alongside the existing composed charter field, so CharterReceipt keeps fingerprinting the same composed text it always did -- unchanged, since the digest covers the full logical charter content regardless of how it is delivered.
This commit is contained in:
@@ -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<String> argv = mutableArgv(argvWithBridge(cfg, spec.charter()));
|
||||
List<String> 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 <role>} 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.
|
||||
*
|
||||
* <p>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<String> argvWithBridge(BridgedConfig.Profile cfg, String charter) {
|
||||
if (!cfg.hasMcp() && charter == null) {
|
||||
private List<String> 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<String> 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).
|
||||
*
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>{@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 <cwd>/<dir1>/<dir2>/<role>.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 <role>} 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);
|
||||
|
||||
@@ -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<String> 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 <role>} flag (CB-617). A role with no such file gets nothing added —
|
||||
* the member must still spawn.
|
||||
*/
|
||||
private List<String> argvWithAgent(List<String> argv, LaunchSpec spec) {
|
||||
Path agentFile = agentDefinitionFile(spec.cwd(), spec.role(), ".opencode", "agent");
|
||||
if (agentFile == null) {
|
||||
return argv;
|
||||
}
|
||||
List<String> withAgent = mutableArgv(argv);
|
||||
withAgent.add("--agent");
|
||||
withAgent.add(spec.role().wireName());
|
||||
return withAgent;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user