From 7655f1b51a2c1b4414e79457bd5ccd2235534de7 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Mon, 24 Aug 2026 06:47:37 +0200 Subject: [PATCH] CB-634: pin the IDE overlay to the module dir + best-effort auto-open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- bridged/fleetd.example.yaml | 12 ++++ .../dev/ltms/fleet/config/FleetConfig.java | 31 +++++++++- .../ltms/fleet/member/ClaudeCodeLauncher.java | 17 +++-- .../ltms/fleet/member/OpenCodeLauncher.java | 11 +++- .../dev/ltms/fleet/peer/PeerLauncher.java | 62 ++++++++++++++++++- .../fleet/member/ClaudeCodeLauncherTest.java | 51 +++++++++++++++ 6 files changed, 174 insertions(+), 10 deletions(-) diff --git a/bridged/fleetd.example.yaml b/bridged/fleetd.example.yaml index be66f0c..ebfe523 100644 --- a/bridged/fleetd.example.yaml +++ b/bridged/fleetd.example.yaml @@ -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 diff --git a/bridged/src/main/java/dev/ltms/fleet/config/FleetConfig.java b/bridged/src/main/java/dev/ltms/fleet/config/FleetConfig.java index b67738f..994d98b 100644 --- a/bridged/src/main/java/dev/ltms/fleet/config/FleetConfig.java +++ b/bridged/src/main/java/dev/ltms/fleet/config/FleetConfig.java @@ -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 argv, + String placement, String workspace, String tabLabel, String mcpUrl, + String cwd, List parityOverlay, String gitTokenEnv, String gitHostEnv, + String kind, Map 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). */ diff --git a/bridged/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java b/bridged/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java index e6103f8..cda7c64 100644 --- a/bridged/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java +++ b/bridged/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java @@ -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 workerEnv = baseEnv(cfg); @@ -368,8 +371,12 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher { * *

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()) { diff --git a/bridged/src/main/java/dev/ltms/fleet/member/OpenCodeLauncher.java b/bridged/src/main/java/dev/ltms/fleet/member/OpenCodeLauncher.java index 3acc7d4..5d3f6b6 100644 --- a/bridged/src/main/java/dev/ltms/fleet/member/OpenCodeLauncher.java +++ b/bridged/src/main/java/dev/ltms/fleet/member/OpenCodeLauncher.java @@ -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 opencode — 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)) { diff --git a/bridged/src/main/java/dev/ltms/fleet/peer/PeerLauncher.java b/bridged/src/main/java/dev/ltms/fleet/peer/PeerLauncher.java index 08d1eb4..8b36f66 100644 --- a/bridged/src/main/java/dev/ltms/fleet/peer/PeerLauncher.java +++ b/bridged/src/main/java/dev/ltms/fleet/peer/PeerLauncher.java @@ -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}. + * + *

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 diff --git a/bridged/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java b/bridged/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java index 2e802ac..e5a7628 100644 --- a/bridged/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java +++ b/bridged/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java @@ -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 /bridged, not . + 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"));