CB-634: pin the IDE overlay to the module dir + best-effort auto-open

The overlay pinned project_path to the worktree root. For a repo whose Maven
module is a subdir (this repo's pom is in `bridged/`, not at the root), opening
the root imports no module and every ide_* call resolves nothing. Pin and open
the module dir instead.

Two new opt-in per-Profile keys, both read only when ideMcpUrl is set:
- ideProjectDir: repo-relative module dir the IDE opens and the overlay pins;
  blank keeps the old worktree-root behaviour.
- ideOpenCommand: host command that opens that dir in the IDE at spawn, with
  {dir} substituted and run through /bin/sh -c so env (e.g. DISPLAY) can be set
  inline. Best-effort and non-fatal — a failure never fails the spawn. Blank
  keeps the manual-open behaviour. No close half yet (deferred).

Shared helpers PeerLauncher.ideProjectPath / openInIde back both launchers.
The two Profile fields ride a back-compat constructor, so every existing call
site and YAML compiles and behaves unchanged.

Tests: overlay content pins the module dir when ideProjectDir is set;
ideProjectPath resolution; openInIde no-op on a blank command. 918 tests green.
This commit is contained in:
Dai Ha
2026-08-24 06:47:37 +02:00
parent a5efb7c676
commit 7655f1b51a
6 changed files with 174 additions and 10 deletions
+12
View File
@@ -130,6 +130,16 @@ herdrSocket: ~/.config/herdr/herdr.sock
# second inline server named `intellij`, and adds an IDE charter that pins every
# ide_* call to the member's own worktree. A URL, not a boolean — host and port
# are host-specific. Set it only on a host where the IDE actually runs.
# ideProjectDir → repo-relative module dir the IDE opens and the overlay pins (CB-634). Only read
# when ideMcpUrl is set. This repo's Maven pom lives in `bridged/`, not at the
# worktree root, so opening the root imports no module and ide_* resolves nothing;
# set this to `bridged`. Omit for a repo whose project is the worktree root.
# ideOpenCommand → host command that opens ideProjectDir in the IDE at spawn (CB-634 auto-open).
# Only read when ideMcpUrl is set. `{dir}` is replaced with the absolute module
# dir and the command runs through `/bin/sh -c`, so set env inline if needed —
# e.g. `env DISPLAY=:10.0 idea {dir}`. Best-effort: a failure is logged, never
# fails the spawn. Omit to open the member's module by hand. There is no close
# half yet — an opened module stays open until the operator closes it.
# tokenEnv → host env var holding the worker's auth token (value never stored in config);
# omit for a backend that needs no token (e.g. a local ollama).
# cwd → pin this profile's working directory (CB-112). Omit to inherit the primary's
@@ -244,6 +254,8 @@ profiles:
# cwd: /Users/me/src/myrepo # pin the working dir; omit to inherit the primary's
# parityOverlay: [".env", ".envrc"] # the default; never add .mcp.json or .claude/settings.local.json — see above
# ideMcpUrl: http://127.0.0.1:29170/index-mcp/streamable-http # opt-in (CB-634): IDE code intelligence, pinned to the worktree
# ideProjectDir: bridged # CB-634: module dir the IDE opens + the overlay pins (this repo's pom is in bridged/)
# ideOpenCommand: env DISPLAY=:10.0 idea {dir} # CB-634 auto-open: opens {dir} in the IDE at spawn; omit to open by hand
gx11: # a second backend, so `placement: weighted` has a choice
baseUrl: http://gx01.gw:8000 # self-hosted; ccs handles the model + token
placement: tab
@@ -290,7 +290,9 @@ public record FleetConfig(
Boolean subscription,
String exhaustedPattern,
String credentialId,
String ideMcpUrl) {
String ideMcpUrl,
String ideProjectDir,
String ideOpenCommand) {
/** Peer kind spawned by {@link dev.ltms.fleet.member.ClaudeCodeLauncher} (the default). */
public static final String KIND_CLAUDE_CODE = "claude-code";
@@ -353,6 +355,13 @@ public record FleetConfig(
// host-specific, mirroring mcpUrl. When set, the member gets the IDE Index MCP mounted
// (pinned to its own worktree via the charter). Blank ⇒ off.
ideMcpUrl = (ideMcpUrl == null || ideMcpUrl.isBlank()) ? null : ideMcpUrl;
// CB-634 auto-open: both are only read when hasIdeMcp(). ideProjectDir is the repo-relative
// module dir IntelliJ must open (this repo's pom lives in `bridged/`, not at the root), and
// it is also the project_path the overlay pins. Blank ⇒ the worktree root (unchanged before
// auto-open). ideOpenCommand is the host command that opens that dir in the IDE, with {dir}
// substituted; blank ⇒ no auto-open (the operator opens the module by hand).
ideProjectDir = (ideProjectDir == null || ideProjectDir.isBlank()) ? null : ideProjectDir;
ideOpenCommand = (ideOpenCommand == null || ideOpenCommand.isBlank()) ? null : ideOpenCommand;
}
/**
@@ -397,7 +406,25 @@ public record FleetConfig(
public Profile withProfile(String p) {
return new Profile(p, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel,
mcpUrl, cwd, parityOverlay, gitTokenEnv, gitHostEnv, kind, env, weight, maxLoad, subscription,
exhaustedPattern, credentialId, ideMcpUrl);
exhaustedPattern, credentialId, ideMcpUrl, ideProjectDir, ideOpenCommand);
}
/**
* Backward-compatible constructor without the CB-634 auto-open fields
* ({@code ideProjectDir}/{@code ideOpenCommand}) — a profile that opts into IDE MCP still
* pins the worktree root and does not auto-open. Keeps pre-auto-open call sites (and any YAML
* that omits the keys) compiling and behaving identically. This is the shape the canonical
* constructor had before the two fields were added.
*/
public Profile(String profile, String baseUrl, String model,
String configDir, String tokenEnv, List<String> argv,
String placement, String workspace, String tabLabel, String mcpUrl,
String cwd, List<String> parityOverlay, String gitTokenEnv, String gitHostEnv,
String kind, Map<String, String> env, Float weight, Integer maxLoad,
Boolean subscription, String exhaustedPattern, String credentialId, String ideMcpUrl) {
this(profile, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel,
mcpUrl, cwd, parityOverlay, gitTokenEnv, gitHostEnv, kind, env, weight, maxLoad,
subscription, exhaustedPattern, credentialId, ideMcpUrl, null, null);
}
/** True when this profile is served by the Claude Code adapter (the default kind). */
@@ -215,9 +215,12 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
// CB-634: the IDE guidance is delivered as an on-disk CLAUDE.local.md overlay, NOT through
// the charter — the charter returns to role -> reply only. Best-effort: a failed overlay
// must never fail the spawn, and `writeIdeOverlay` no-ops unless the cwd is a provisioned
// worktree (see its .git-file safety gate).
// worktree (see its .git-file safety gate). The overlay pins, and the auto-open opens, the
// module dir (this repo's pom is in `bridged/`, not at the worktree root) — see ideProjectPath.
if (cfg.hasIdeMcp()) {
writeIdeOverlay(spec.cwd());
String projectPath = PeerLauncher.ideProjectPath(spec.cwd(), cfg.ideProjectDir());
writeIdeOverlay(spec.cwd(), projectPath);
PeerLauncher.openInIde(projectPath, cfg.ideOpenCommand(), log);
}
Map<String, String> workerEnv = baseEnv(cfg);
@@ -368,8 +371,12 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
*
* <p>Best-effort: a failure is logged at debug and swallowed — a failed overlay must never fail
* the spawn.
*
* @param cwd the member's worktree root, where the {@code CLAUDE.local.md} file is written
* @param projectPath the module dir the overlay pins {@code project_path} to (see
* {@link PeerLauncher#ideProjectPath}); equals {@code cwd} when no module subdir
*/
private static void writeIdeOverlay(String cwd) {
private static void writeIdeOverlay(String cwd, String projectPath) {
try {
Path dotGit = Path.of(cwd, ".git");
if (!Files.isRegularFile(dotGit)) {
@@ -377,7 +384,9 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
// cwd is not a repo at all). Never write into it.
return;
}
Files.writeString(Path.of(cwd, "CLAUDE.local.md"), PeerLauncher.ideOverlayText(cwd));
// The overlay FILE lives at the worktree root (claude-code's cwd), but its CONTENT pins
// project_path to the module dir the IDE opened (projectPath), not the worktree root.
Files.writeString(Path.of(cwd, "CLAUDE.local.md"), PeerLauncher.ideOverlayText(projectPath));
String gitdirLine = Files.readString(dotGit).trim();
Path gitDir = Path.of(gitdirLine.replaceFirst("^gitdir:\\s*", ""));
if (!gitDir.isAbsolute()) {
@@ -24,6 +24,9 @@ import java.util.function.Function;
import java.util.function.LongSupplier;
import java.util.function.Supplier;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link HerdrPeerLauncher} adapter for <strong>opencode</strong> — an open-source,
* provider-agnostic terminal coding agent. Its whole reason for existing is to prove the
@@ -50,6 +53,8 @@ import java.util.function.Supplier;
*/
public final class OpenCodeLauncher extends HerdrPeerLauncher {
private static final Logger log = LoggerFactory.getLogger(OpenCodeLauncher.class);
/** Label prefix for this adapter's herdr agent names (drives naming + orphan reap). */
private static final String NAME_PREFIX = "opencode";
@@ -341,11 +346,15 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher {
ide.put("url", cfg.ideMcpUrl());
ide.put("enabled", true);
// CB-634: pin the overlay and open the IDE at the module dir (this repo's pom is
// in `bridged/`, not at the worktree root) — see PeerLauncher.ideProjectPath.
String projectPath = PeerLauncher.ideProjectPath(cwd, cfg.ideProjectDir());
Path rules = dir.resolve("ide-rules.md");
Files.writeString(rules, PeerLauncher.ideOverlayText(cwd));
Files.writeString(rules, PeerLauncher.ideOverlayText(projectPath));
rules.toFile().deleteOnExit();
// The array may already hold the member-charter path; withArray gets-or-creates.
root.withArray("instructions").add(rules.toAbsolutePath().toString());
PeerLauncher.openInIde(projectPath, cfg.ideOpenCommand(), log);
}
}
if (hasCustomProvider(cfg)) {
@@ -1,8 +1,11 @@
package dev.ltms.fleet.peer;
import java.nio.file.Path;
import java.util.List;
import java.util.Set;
import org.slf4j.Logger;
/**
* SPI for materializing a connected peer — the only way the bridge core creates or tears down
* a peer process. Every launcher is a first-party, in-tree adapter selected by (future) profile
@@ -36,20 +39,73 @@ public interface PeerLauncher {
* clobbered. It pins every {@code ide_*} call to the member's own worktree, which is the whole
* point of the mechanism. Both launchers render their own overlay from this single source.
*
* @param worktree the member's isolated worktree path, embedded as the {@code project_path} pin
* @param projectPath the path the member must pin every {@code ide_*} call to — the module dir
* IntelliJ opened as the project, which is {@link #ideProjectPath} of the
* member's own worktree (the worktree root when no module subdir is set)
*/
static String ideOverlayText(String worktree) {
static String ideOverlayText(String projectPath) {
return "## IDE code intelligence — your worktree only\n"
+ "An IntelliJ IDE Index MCP server is mounted as `mcp__intellij__ide_*`. Prefer it "
+ "over `grep`/`find` for symbol lookups, references, call and type hierarchy, and "
+ "diagnostics — it resolves the real AST, text search does not.\n\n"
+ "Every `ide_*` call MUST pass `project_path: \"" + worktree + "\"` — your own "
+ "Every `ide_*` call MUST pass `project_path: \"" + projectPath + "\"` — your own "
+ "worktree — and never any other path. A call without it errors "
+ "`multiple_projects_open`; a call with a different path reads another checkout, "
+ "not your changes. This is not the primary's IDE: it is your worktree, pinned to "
+ "you.";
}
/**
* The absolute path IntelliJ must open as the project, and the {@code project_path} the overlay
* pins (CB-634). It is {@code cwd} resolved against {@code ideProjectDir}. The distinction
* matters because this repo (like {@code fleet/fleetd}) keeps its Maven module in a subdir
* ({@code bridged/}), not at the worktree root: opening the root imports no module and
* {@code ide_*} resolves nothing, so the module dir is the correct pin and open target.
*
* @param cwd the member's worktree root
* @param ideProjectDir repo-relative module subdir, or {@code null}/blank for the worktree root
* @return the absolute, normalized module dir as a string
*/
static String ideProjectPath(String cwd, String ideProjectDir) {
Path base = Path.of(cwd);
if (ideProjectDir == null || ideProjectDir.isBlank()) {
return base.toString();
}
return base.resolve(ideProjectDir).normalize().toString();
}
/**
* Best-effort: open {@code projectPath} in the host IDE by running {@code openCommand} with
* every {@code {dir}} replaced by {@code projectPath} (CB-634 auto-open). The command runs
* through {@code /bin/sh -c} so an operator can set env inline — e.g.
* {@code "env DISPLAY=:10.0 idea {dir}"} — because the daemon's own env may lack {@code DISPLAY}.
*
* <p>A blank command is a no-op: the profile opted into IDE MCP but not auto-open, so the
* operator opens the module by hand. The child process is detached and its exit is not awaited;
* any failure is logged and swallowed, because a member must spawn whether or not an IDE is
* running. There is no close half yet (CB-634 defers it): an opened module stays open until the
* operator closes it, and opening the same module again just refocuses it.
*
* @param projectPath the module dir to open (typically {@link #ideProjectPath})
* @param openCommand the host command template, with {@code {dir}} substituted; null/blank ⇒ no-op
* @param log the calling launcher's logger, for the best-effort WARN
*/
static void openInIde(String projectPath, String openCommand, Logger log) {
if (openCommand == null || openCommand.isBlank()) {
return;
}
String cmd = openCommand.replace("{dir}", projectPath);
try {
new ProcessBuilder("/bin/sh", "-c", cmd)
.redirectOutput(ProcessBuilder.Redirect.DISCARD)
.redirectError(ProcessBuilder.Redirect.DISCARD)
.start();
log.info("CB-634 auto-open: launched IDE open for {}", projectPath);
} catch (Exception e) {
log.warn("CB-634 auto-open of '{}' failed (member still spawns): {}", projectPath, e.getMessage());
}
}
/**
* The set of {@link Capability capabilities} this launcher declares. A peer whose profile
* opts into a git-forge token should include {@link Capability#SELF_PR}; the base set for
@@ -12,6 +12,7 @@ import dev.ltms.fleet.herdr.WorkspaceControl;
import dev.ltms.fleet.peer.Capability;
import dev.ltms.fleet.peer.MemberRole;
import dev.ltms.fleet.peer.PeerHandle;
import dev.ltms.fleet.peer.PeerLauncher;
import dev.ltms.fleet.peer.PeerUnreachableException;
import dev.ltms.fleet.peer.SpawnRequest;
import org.junit.jupiter.api.Test;
@@ -74,6 +75,15 @@ class ClaudeCodeLauncherTest {
null, null, null, null, null, null, null, null, null, ideMcpUrl);
}
/** As {@link #ideProfile} but carrying the CB-634 auto-open fields (module subdir + open command). */
private FleetConfig.Profile ideProfileModule(String ideMcpUrl, String cwd, String ideProjectDir,
String ideOpenCommand) {
return new FleetConfig.Profile(
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
List.of("claude"), "tab", "bridged-workers", "w #{n}", null, cwd, null,
null, null, null, null, null, null, null, null, null, ideMcpUrl, ideProjectDir, ideOpenCommand);
}
private ClaudeCodeLauncher launcher(FakeHerdr herdr, FleetConfig.Profile cfg) {
return new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null);
@@ -167,6 +177,47 @@ class ClaudeCodeLauncherTest {
"the entry must NOT go to the per-worktree gitdir, which git ignores for excludes");
}
// CB-634 auto-open pin fix: for a repo whose Maven module is a subdir (this repo's pom lives in
// `bridged/`, not at the worktree root), the overlay must pin project_path to the MODULE dir the
// IDE opened, not the worktree root — else ide_* resolves against a root that imports no module.
@Test
void writeIdeOverlayPinsModuleDirWhenIdeProjectDirSet(@TempDir Path root) throws Exception {
Path worktree = Files.createDirectory(root.resolve("worktree"));
Path commonDir = Files.createDirectory(root.resolve("dotgit"));
Path gitDir = Files.createDirectories(commonDir.resolve("worktrees").resolve("wt1"));
Files.writeString(worktree.resolve(".git"), "gitdir: " + gitDir);
FakeHerdr herdr = new FakeHerdr();
// ideProjectDir "bridged" ⇒ the pin is <worktree>/bridged, not <worktree>.
launcher(herdr, ideProfileModule("http://127.0.0.1:29170/index-mcp/streamable-http",
worktree.toString(), "bridged", null)).spawn();
Path overlay = worktree.resolve("CLAUDE.local.md");
assertTrue(Files.exists(overlay), "the overlay file still lives at the worktree root");
String module = worktree.resolve("bridged").toString();
assertTrue(Files.readString(overlay).contains("project_path: \"" + module + "\""),
"the overlay pins the MODULE dir the IDE opened, not the worktree root");
assertFalse(Files.readString(overlay).contains("project_path: \"" + worktree + "\""),
"the bare worktree root must NOT be the pin when a module subdir is set");
}
@Test
void ideProjectPathResolvesModuleSubdirAndDefaultsToWorktreeRoot() {
assertEquals("/wt/x/bridged", PeerLauncher.ideProjectPath("/wt/x", "bridged"),
"a module subdir resolves under the worktree root");
assertEquals("/wt/x", PeerLauncher.ideProjectPath("/wt/x", null),
"no module subdir ⇒ the worktree root itself");
assertEquals("/wt/x", PeerLauncher.ideProjectPath("/wt/x", " "),
"a blank module subdir ⇒ the worktree root itself");
}
@Test
void openInIdeIsNoOpAndNeverThrowsWhenCommandBlank() {
// A profile that opts into IDE MCP but sets no open command must not fail the spawn.
assertDoesNotThrow(() -> PeerLauncher.openInIde("/wt/x", null, LoggerFactory.getLogger("test")));
assertDoesNotThrow(() -> PeerLauncher.openInIde("/wt/x", " ", LoggerFactory.getLogger("test")));
}
@Test
void writeIdeOverlayDoesNothingWhenDotGitIsADirectory(@TempDir Path root) throws Exception {
Path worktree = Files.createDirectory(root.resolve("worktree"));