diff --git a/bridged/src/main/java/dev/ltms/bridged/Bridged.java b/bridged/src/main/java/dev/ltms/bridged/Bridged.java
index d0ec531..18b23ff 100644
--- a/bridged/src/main/java/dev/ltms/bridged/Bridged.java
+++ b/bridged/src/main/java/dev/ltms/bridged/Bridged.java
@@ -20,6 +20,7 @@ import dev.ltms.bridged.msg.Rendezvous;
import dev.ltms.bridged.rest.BridgedApp;
import dev.ltms.bridged.session.GitWorktrees;
import dev.ltms.bridged.session.SessionManager;
+import dev.ltms.bridged.peer.PeerLauncher;
import dev.ltms.bridged.session.SessionReaper;
import dev.ltms.bridged.worker.WorkerService;
import io.javalin.Javalin;
@@ -56,7 +57,7 @@ public final class Bridged {
AgentControl agents = new AgentControl(herdr);
WorkspaceControl spaces = new WorkspaceControl(herdr);
- WorkerService workers = new WorkerService(agents, spaces, guard,
+ PeerLauncher workers = new WorkerService(agents, spaces, guard,
cfg.workerProfiles(), cfg.defaultProfile(), System::getenv);
// CB-117: herdr keeps worker panes alive across a daemon restart, and their ids died with
// the previous process — reap those leaked orphans now, before we start serving.
@@ -120,7 +121,8 @@ public final class Bridged {
// Caller identity is resolved from the connection (peer PID → herdr pane), not arguments.
ConnectionIdentity identity = new ConnectionIdentity(
new PaneLocator(herdr), new LsofPeerPidLookup(), new LsofProcessCwdLookup());
- BridgeMcp mcp = new BridgeMcp(messages, rendezvous, workers, sessions, identity, presence);
+ // Cast to WorkerService: BridgeMcp is not yet migrated to PeerLauncher (Stage A scope).
+ BridgeMcp mcp = new BridgeMcp(messages, rendezvous, (WorkerService) workers, sessions, identity, presence);
// CB-303 part 3: single ordered shutdown hook. Drain sessions first while herdr is still
// open (so releases reach the daemon), then stop poller/message/mcp/reaper, and close herdr
@@ -134,7 +136,7 @@ public final class Bridged {
herdr.close();
}));
- Javalin app = new BridgedApp(herdr, workers, sessions, messages, rendezvous, presence, mcp.servlet()).build();
+ Javalin app = new BridgedApp(herdr, (WorkerService) workers, sessions, messages, rendezvous, presence, mcp.servlet()).build();
app.start(cfg.bind().host(), cfg.bind().port());
log.info("bridged listening on {}:{}, herdr socket {}",
cfg.bind().host(), cfg.bind().port(), socket);
diff --git a/bridged/src/main/java/dev/ltms/bridged/peer/Capability.java b/bridged/src/main/java/dev/ltms/bridged/peer/Capability.java
new file mode 100644
index 0000000..14adfbc
--- /dev/null
+++ b/bridged/src/main/java/dev/ltms/bridged/peer/Capability.java
@@ -0,0 +1,35 @@
+package dev.ltms.bridged.peer;
+
+/**
+ * Declared capabilities of a {@link PeerLauncher}. The protocol is the union across all
+ * configured launchers; a verb invoked against a peer that lacks the capability returns a clean
+ * "unsupported for this peer" rather than a crash. Capabilities keep the protocol honest as peers
+ * diversify and prevent the core from assuming "every peer is a Claude in a worktree."
+ */
+public enum Capability {
+
+ /**
+ * The peer supports {@code bridge_ask} rendezvous — pausing its delegated turn to ask
+ * the primary a question, then resuming once answered. All Claude Code peers support this.
+ */
+ MID_TURN_ASK,
+
+ /**
+ * The peer can open its own PR at the end of an implementation turn (CB-302). Opt-in per
+ * profile: granted only when the profile carries a git-forge token ({@code gitTokenEnv}).
+ */
+ SELF_PR,
+
+ /**
+ * The peer can run inside a provisioned isolated git worktree. All CLI-based peers support
+ * this since their cwd is set at spawn time.
+ */
+ WORKTREE,
+
+ /**
+ * The spawner can reconcile orphaned peers on boot — workers that outlived a prior daemon
+ * process and whose pane ids died with it (CB-117). Claude Code over herdr supports this
+ * via name-based matching against the herdr agent list.
+ */
+ ORPHAN_REAP
+}
diff --git a/bridged/src/main/java/dev/ltms/bridged/peer/PeerHandle.java b/bridged/src/main/java/dev/ltms/bridged/peer/PeerHandle.java
new file mode 100644
index 0000000..dd8ae7c
--- /dev/null
+++ b/bridged/src/main/java/dev/ltms/bridged/peer/PeerHandle.java
@@ -0,0 +1,29 @@
+package dev.ltms.bridged.peer;
+
+/**
+ * An opaque handle returned by {@link PeerLauncher#spawn(SpawnRequest)}. The core routes on
+ * {@link #id()} (the registry/routing key) and uses {@link #terminalId()} for session tracking;
+ * launcher-private coordinates beyond these are reachable through the concrete implementation.
+ *
+ *
A {@link PeerHandle} is returned after the peer process is live — the launcher
+ * has already completed subscription-guarded env/vfs setup, process start, and placement. The
+ * handle is a ticket the core exchanges for the running peer, not a lazy/delayed reference.
+ */
+public interface PeerHandle {
+
+ /**
+ * The registry/routing key — an opaque, launcher-assigned identifier. For the herdr-backed
+ * launcher this is the herdr pane id; for other launchers it is whatever their transport
+ * uses. Guaranteed to be non-null and unique among live peers within a single daemon process.
+ */
+ String id();
+
+ /**
+ * The transport-level session identifier used for message routing and presence tracking.
+ * For the herdr launcher this is the herdr terminal UUID. A non-herdr launcher may return
+ * its own analogous identifier, or {@code null} if the concept does not apply.
+ */
+ default String terminalId() {
+ return null;
+ }
+}
diff --git a/bridged/src/main/java/dev/ltms/bridged/peer/PeerLauncher.java b/bridged/src/main/java/dev/ltms/bridged/peer/PeerLauncher.java
new file mode 100644
index 0000000..c987ab4
--- /dev/null
+++ b/bridged/src/main/java/dev/ltms/bridged/peer/PeerLauncher.java
@@ -0,0 +1,85 @@
+package dev.ltms.bridged.peer;
+
+import java.util.List;
+import java.util.Set;
+
+/**
+ * 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
+ * config; today's single adapter is the {@code WorkerService} / Claude Code over herdr.
+ *
+ *
The core delegates spawn and teardown to this interface without knowing how the peer is set
+ * up. Environment variables, CLI flags, subscription guards, transport (herdr tab/pane) layout,
+ * and naming conventions are all adapter-private — the core sees only the returned
+ * {@link PeerHandle} whose {@code id()} is the registry/routing key.
+ *
+ *
The interface is a superset of what {@code SessionManager} and {@code Bridged.main} call
+ * on the concrete launcher today.
+ */
+public interface PeerLauncher {
+
+ /**
+ * 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
+ * the Claude Code herdr adapter is always {@code MID_TURN_ASK, WORKTREE, ORPHAN_REAP}.
+ */
+ Set capabilities();
+
+ /**
+ * {@code profileName}/requestedCwd null/blank → default resolution. Returns after the peer
+ * process is live (env + argv + placement complete). Never returns {@code null}.
+ *
+ * @param req the spawn parameters (profile, requested cwd, caller cwd)
+ * @return a handle whose {@link PeerHandle#id()} is the registry/routing key
+ * @throws IllegalArgumentException if the profile is unknown and no default is configured
+ */
+ PeerHandle spawn(SpawnRequest req);
+
+ /**
+ * The configured worker profile names — the set of names {@code spawn(profileName)} accepts.
+ */
+ Set profiles();
+
+ /**
+ * The profile a no-argument {@link #spawn(SpawnRequest)} uses, or {@code null} if none is configured.
+ */
+ String defaultProfile();
+
+ /**
+ * Resolve the effective working directory for a spawn {@code req} without actually spawning.
+ * Resolution order: requestedCwd → profile cwd → callerCwd → daemon cwd.
+ *
+ * @return the resolved absolute path, never null/blank
+ */
+ String effectiveCwd(SpawnRequest req);
+
+ /**
+ * The parity-overlay file list for {@code profileName} (default list when unset). Used by
+ * worktree provisioning to copy config files into the isolated checkout before spawning.
+ */
+ List parityOverlay(String profileName);
+
+ /**
+ * The set of all agents this launcher currently tracks, transport-specific. Each element
+ * exposes at minimum a pane-like {@code id()} matching this launcher's {@link PeerHandle}
+ * scheme, plus transport-level status. Callers merge this set with the session registry to
+ * build a live roster view.
+ */
+ List> list();
+
+ /**
+ * Reap orphaned peers left behind by a prior daemon process. Only peers whose naming scheme
+ * matches this launcher's and whose nonce differs from the current process are eligible.
+ * Best-effort: a failure to list or to stop any one peer is logged and never aborts startup.
+ *
+ * @return the number of orphaned peers reaped
+ */
+ int reapOrphanWorkers();
+
+ /**
+ * Tear a peer down by its registry/routing key ({@link PeerHandle#id()}). Tolerates an
+ * already-gone peer. Also cleans up launcher-private resources (e.g. empty dedicated tabs)
+ * when safe to do so.
+ */
+ void stop(String id);
+}
diff --git a/bridged/src/main/java/dev/ltms/bridged/peer/SpawnRequest.java b/bridged/src/main/java/dev/ltms/bridged/peer/SpawnRequest.java
new file mode 100644
index 0000000..7aab58a
--- /dev/null
+++ b/bridged/src/main/java/dev/ltms/bridged/peer/SpawnRequest.java
@@ -0,0 +1,13 @@
+package dev.ltms.bridged.peer;
+
+/**
+ * Parameters for a {@link PeerLauncher#spawn(SpawnRequest)} call — the peer-neutral
+ * aggregation of what the core knows at delegation time: which profile to use, the caller's
+ * requested working directory, and the caller's own cwd (to inherit when no other cwd is set).
+ *
+ * A null or blank {@code profileName} means "use the launcher's default profile."
+ * A null or blank {@code requestedCwd} means "inherit from config or caller."
+ * A null {@code callerCwd} means "the request came from the daemon itself (not a primary)."
+ */
+public record SpawnRequest(String profileName, String requestedCwd, String callerCwd) {
+}
diff --git a/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java b/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java
index f1883a2..83da133 100644
--- a/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java
+++ b/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java
@@ -3,7 +3,9 @@ package dev.ltms.bridged.session;
import dev.ltms.bridged.herdr.Agent;
import dev.ltms.bridged.inject.TurnListener;
import dev.ltms.bridged.inject.WorkerPresence;
-import dev.ltms.bridged.worker.WorkerService;
+import dev.ltms.bridged.peer.PeerHandle;
+import dev.ltms.bridged.peer.PeerLauncher;
+import dev.ltms.bridged.peer.SpawnRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -19,8 +21,9 @@ import java.util.function.LongSupplier;
/**
* Authoritative in-daemon registry of the worker sessions this {@code bridged} process spawned.
- * Wraps {@link WorkerService} (it still performs subscription-guarded spawn/teardown) and adds
- * lifecycle tracking, ownership, and deterministic teardown on top.
+ * Delegates spawn/teardown to a {@link PeerLauncher} (which performs subscription-guarded env
+ * setup and process/materialization) and adds lifecycle tracking, ownership, and deterministic
+ * teardown on top.
*
*
The state machine is intentionally one-shot / no-reuse: every acquired worker is fresh,
* and a finished or released worker is torn down, never pooled. {@link #recycle} is a convenience
@@ -35,7 +38,7 @@ public final class SessionManager implements TurnListener {
private static final Logger log = LoggerFactory.getLogger(SessionManager.class);
- private final WorkerService workerService;
+ private final PeerLauncher launcher;
private final Worktrees worktrees;
private final ConcurrentHashMap registry = new ConcurrentHashMap<>();
private final WorkerPresence presence;
@@ -45,28 +48,28 @@ public final class SessionManager implements TurnListener {
private final int contextCap;
/** Backward-compatible constructor: shared-tree sessions, production git seam. */
- public SessionManager(WorkerService workerService) {
- this(workerService, new GitWorktrees(), System::nanoTime, 0);
+ public SessionManager(PeerLauncher launcher) {
+ this(launcher, new GitWorktrees(), System::nanoTime, 0);
}
/** Backward-compatible constructor with an injectable worktree seam. */
- public SessionManager(WorkerService workerService, Worktrees worktrees) {
- this(workerService, worktrees, System::nanoTime, 0);
+ public SessionManager(PeerLauncher launcher, Worktrees worktrees) {
+ this(launcher, worktrees, System::nanoTime, 0);
}
/** Test constructor with an injectable clock. */
- public SessionManager(WorkerService workerService, Worktrees worktrees, LongSupplier nowNanos) {
- this(workerService, worktrees, nowNanos, 0);
+ public SessionManager(PeerLauncher launcher, Worktrees worktrees, LongSupplier nowNanos) {
+ this(launcher, worktrees, nowNanos, 0);
}
/** Production constructor with a configured context turn cap. */
- public SessionManager(WorkerService workerService, Worktrees worktrees, int contextCap) {
- this(workerService, worktrees, System::nanoTime, contextCap);
+ public SessionManager(PeerLauncher launcher, Worktrees worktrees, int contextCap) {
+ this(launcher, worktrees, System::nanoTime, contextCap);
}
- public SessionManager(WorkerService workerService, Worktrees worktrees, LongSupplier nowNanos,
+ public SessionManager(PeerLauncher launcher, Worktrees worktrees, LongSupplier nowNanos,
int contextCap) {
- this.workerService = workerService;
+ this.launcher = launcher;
this.worktrees = worktrees;
this.presence = new PresenceBridge(this);
this.nowNanos = nowNanos;
@@ -101,15 +104,17 @@ public final class SessionManager implements TurnListener {
public WorkerSession acquire(String profile, String requestedCwd, String callerCwd,
String ownerTerminal, WorktreeRequest wt) {
if (wt == null) {
- Agent worker = workerService.spawn(profile, requestedCwd, callerCwd);
+ SpawnRequest req = new SpawnRequest(profile, requestedCwd, callerCwd);
+ PeerHandle handle = launcher.spawn(req);
String resolvedProfile = (profile == null || profile.isBlank())
- ? workerService.defaultProfile() : profile;
+ ? launcher.defaultProfile() : profile;
+ String cwd = launcher.effectiveCwd(req);
long now = nowNanos.getAsLong();
WorkerSession session = new WorkerSession(
- worker.paneId(),
- worker.terminalId(),
+ handle.id(),
+ handle.terminalId(),
resolvedProfile,
- resolveCwd(requestedCwd, profile, callerCwd),
+ cwd,
ownerTerminal,
now,
now,
@@ -117,9 +122,9 @@ public final class SessionManager implements TurnListener {
WorkerSession.State.SPAWNING,
null,
null);
- registry.put(session.paneId(), session);
- log.debug("acquired session pane={} terminal={} profile={} owner={}",
- session.paneId(), session.terminalId(), session.profile(), session.ownerTerminal());
+ registry.put(handle.id(), session);
+ log.debug("acquired session id={} terminal={} profile={} owner={}",
+ handle.id(), handle.terminalId(), session.profile(), session.ownerTerminal());
return session;
}
return acquireWithWorktree(profile, requestedCwd, callerCwd, ownerTerminal, wt);
@@ -132,7 +137,7 @@ public final class SessionManager implements TurnListener {
log.debug("releasing session pane={} terminal={} state={}",
removed.paneId(), removed.terminalId(), removed.state());
}
- workerService.stop(paneId);
+ launcher.stop(paneId);
if (removed != null && removed.worktree() != null) {
worktrees.remove(worktrees.repoRoot(removed.cwd()), removed.worktree());
}
@@ -141,15 +146,15 @@ public final class SessionManager implements TurnListener {
private WorkerSession acquireWithWorktree(String profile, String requestedCwd, String callerCwd,
String ownerTerminal, WorktreeRequest wt) {
String resolvedProfile = (profile == null || profile.isBlank())
- ? workerService.defaultProfile() : profile;
+ ? launcher.defaultProfile() : profile;
String repoRoot = worktrees.repoRoot(firstNonBlank(requestedCwd, callerCwd));
String branch = "worker/" + slug(wt.ticketSlug()) + "-" + nonce();
String path = null;
- Agent worker;
+ PeerHandle handle;
try {
path = worktrees.add(repoRoot, branch, wt.baseRef());
- worktrees.overlayParity(repoRoot, path, workerService.parityOverlay(resolvedProfile));
- worker = workerService.spawn(profile, path, callerCwd);
+ worktrees.overlayParity(repoRoot, path, launcher.parityOverlay(resolvedProfile));
+ handle = launcher.spawn(new SpawnRequest(profile, path, callerCwd));
} catch (RuntimeException e) {
if (path != null) {
try {
@@ -162,8 +167,8 @@ public final class SessionManager implements TurnListener {
}
long now = nowNanos.getAsLong();
WorkerSession session = new WorkerSession(
- worker.paneId(),
- worker.terminalId(),
+ handle.id(),
+ handle.terminalId(),
resolvedProfile,
resolveCwd(path, profile, callerCwd),
ownerTerminal,
@@ -173,9 +178,9 @@ public final class SessionManager implements TurnListener {
WorkerSession.State.SPAWNING,
path,
branch);
- registry.put(session.paneId(), session);
- log.debug("acquired worktree session pane={} terminal={} profile={} branch={} path={}",
- session.paneId(), session.terminalId(), session.profile(), session.branch(), session.worktree());
+ registry.put(handle.id(), session);
+ log.debug("acquired worktree session id={} terminal={} profile={} branch={} path={}",
+ handle.id(), handle.terminalId(), session.profile(), session.branch(), session.worktree());
return session;
}
@@ -386,7 +391,7 @@ public final class SessionManager implements TurnListener {
}
private String resolveCwd(String requestedCwd, String profileName, String callerCwd) {
- return workerService.effectiveCwd(profileName, requestedCwd, callerCwd);
+ return launcher.effectiveCwd(new SpawnRequest(profileName, requestedCwd, callerCwd));
}
/** WorkerPresence bridge that also drives the manager's READY transition. */
diff --git a/bridged/src/main/java/dev/ltms/bridged/worker/WorkerService.java b/bridged/src/main/java/dev/ltms/bridged/worker/WorkerService.java
index 96a734d..7b7fc50 100644
--- a/bridged/src/main/java/dev/ltms/bridged/worker/WorkerService.java
+++ b/bridged/src/main/java/dev/ltms/bridged/worker/WorkerService.java
@@ -8,11 +8,16 @@ import dev.ltms.bridged.herdr.HerdrException;
import dev.ltms.bridged.herdr.Tab;
import dev.ltms.bridged.herdr.Workspace;
import dev.ltms.bridged.herdr.WorkspaceControl;
+import dev.ltms.bridged.peer.Capability;
+import dev.ltms.bridged.peer.PeerHandle;
+import dev.ltms.bridged.peer.PeerLauncher;
+import dev.ltms.bridged.peer.SpawnRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.security.SecureRandom;
import java.util.ArrayList;
+import java.util.EnumSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -37,7 +42,7 @@ import java.util.regex.Pattern;
* clutter the user's real work spaces. Teardown removes the worker's pane and its
* now-empty tab, tolerating an already-gone worker so a repeated DELETE is harmless.
*/
-public final class WorkerService {
+public final class WorkerService implements PeerLauncher {
private static final Logger log = LoggerFactory.getLogger(WorkerService.class);
@@ -93,11 +98,13 @@ public final class WorkerService {
}
/** The configured worker profile names (what {@code spawn(profile)} accepts). */
+ @Override
public Set profiles() {
return profiles.keySet();
}
/** The parity-overlay file list for {@code profileName} (default list when unset). */
+ @Override
public List parityOverlay(String profileName) {
String name = (profileName == null || profileName.isBlank()) ? defaultProfile : profileName;
if (name == null || name.isBlank()) {
@@ -108,6 +115,7 @@ public final class WorkerService {
}
/** The profile a no-argument {@link #spawn()} uses, or {@code null} if none is configured. */
+ @Override
public String defaultProfile() {
return defaultProfile;
}
@@ -318,6 +326,7 @@ public final class WorkerService {
}
/** All herdr-tracked agents — discovery for "what workers exist". */
+ @Override
public List list() {
return agents.list();
}
@@ -336,6 +345,7 @@ public final class WorkerService {
*
* @return the number of orphaned workers reaped
*/
+ @Override
public int reapOrphanWorkers() {
List all;
try {
@@ -397,6 +407,7 @@ public final class WorkerService {
* (repeated DELETE, crashed worker) is treated as success; any other failure propagates so
* a genuinely failed teardown is not reported as done.
*/
+ @Override
public void stop(String paneId) {
// Teardown knows only the paneId, not which profile spawned it. Attempt tab cleanup when any
// profile uses tab placement (so the bridge may have created a dedicated worker tab); the
@@ -426,6 +437,44 @@ public final class WorkerService {
return e.code() != null && e.code().endsWith("_not_found");
}
+ // --- PeerLauncher SPI -------------------------------------------------------------------
+
+ @Override
+ public Set capabilities() {
+ Set caps = EnumSet.of(Capability.MID_TURN_ASK, Capability.WORKTREE, Capability.ORPHAN_REAP);
+ if (hasGitTokenProfile()) {
+ caps.add(Capability.SELF_PR);
+ }
+ return Set.copyOf(caps);
+ }
+
+ /** Whether any configured profile opts into a git-forge token (required for {@link Capability#SELF_PR}). */
+ private boolean hasGitTokenProfile() {
+ return profiles.values().stream().anyMatch(BridgedConfig.Worker::hasGitToken);
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * Delegates to the three-arg {@link #spawn(String, String, String)} and wraps the
+ * resulting herdr {@link Agent} in a {@link WorkerHandle} whose {@link PeerHandle#id()}
+ * equals the agent's paneId.
+ */
+ @Override
+ public PeerHandle spawn(SpawnRequest req) {
+ Agent agent = spawn(req.profileName(), req.requestedCwd(), req.callerCwd());
+ return new WorkerHandle(agent.paneId(), agent.terminalId());
+ }
+
+ /** A concrete {@link PeerHandle} wrapping herdr agent coordinates. */
+ private record WorkerHandle(String id, String terminalId) implements PeerHandle {
+ }
+
+ @Override
+ public String effectiveCwd(SpawnRequest req) {
+ return effectiveCwd(req.profileName(), req.requestedCwd(), req.callerCwd());
+ }
+
private static void putIfPresent(Map m, String k, String v) {
if (v != null && !v.isBlank()) {
m.put(k, v);
diff --git a/bridged/src/test/java/dev/ltms/bridged/worker/WorkerServiceTest.java b/bridged/src/test/java/dev/ltms/bridged/worker/WorkerServiceTest.java
index 200aefc..6cd5a4e 100644
--- a/bridged/src/test/java/dev/ltms/bridged/worker/WorkerServiceTest.java
+++ b/bridged/src/test/java/dev/ltms/bridged/worker/WorkerServiceTest.java
@@ -5,6 +5,9 @@ import dev.ltms.bridged.guard.SubscriptionGuard;
import dev.ltms.bridged.herdr.AgentControl;
import dev.ltms.bridged.herdr.FakeHerdr;
import dev.ltms.bridged.herdr.WorkspaceControl;
+import dev.ltms.bridged.peer.Capability;
+import dev.ltms.bridged.peer.PeerHandle;
+import dev.ltms.bridged.peer.SpawnRequest;
import org.junit.jupiter.api.Test;
import java.util.List;
@@ -219,4 +222,100 @@ class WorkerServiceTest {
FakeHerdr herdr = new FakeHerdr().healthy(false); // agent.list throws
assertEquals(0, multiProfile(herdr).reapOrphanWorkers(), "a listing failure reaps nothing and does not throw");
}
+
+ // --- PeerHandle indirection ----------------------------------------------------------------
+
+ @Test
+ void spawnReturnsPeerHandleWithIdEqualToPaneId() {
+ FakeHerdr herdr = new FakeHerdr();
+ WorkerService svc = service(herdr, List.of("ccs", "ltms-local"), null);
+
+ PeerHandle handle = svc.spawn(new SpawnRequest(null, null, null));
+
+ assertNotNull(handle, "spawn must return a non-null handle");
+ assertEquals("w9:pW_1", handle.id(), "handle.id() must equal the agent's paneId");
+ }
+
+ @Test
+ void spawnReturnsPeerHandleWithCorrectTerminalId() {
+ FakeHerdr herdr = new FakeHerdr();
+ WorkerService svc = service(herdr, List.of("ccs", "ltms-local"), null);
+
+ PeerHandle handle = svc.spawn(new SpawnRequest("ltms-local", null, "/caller"));
+
+ assertEquals("term_new_1", handle.terminalId(), "handle.terminalId() must equal the agent's terminalId");
+ }
+
+ @Test
+ void capabilitiesIncludeMidTurnAskWorktreeOrphanReap() {
+ FakeHerdr herdr = new FakeHerdr();
+ WorkerService svc = service(herdr, List.of("ccs", "ltms-local"), null);
+
+ Set caps = svc.capabilities();
+
+ assertTrue(caps.contains(Capability.MID_TURN_ASK), "every Claude Code peer supports mid-turn ask");
+ assertTrue(caps.contains(Capability.WORKTREE), "every CLI peer supports worktree cwd");
+ assertTrue(caps.contains(Capability.ORPHAN_REAP), "every herdr launcher supports orphan reap");
+ }
+
+ @Test
+ void capabilitiesIncludeSelfPrWhenProfileHasGitToken() {
+ FakeHerdr herdr = new FakeHerdr();
+ BridgedConfig.Worker cfg = new BridgedConfig.Worker(
+ "impl", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
+ List.of("ccs", "impl"), "tab", "bridged-workers", "w #{n}", null, null, null,
+ "GITEA_ACCESS_TOKEN", null);
+ WorkerService svc = new WorkerService(new AgentControl(herdr), new WorkspaceControl(herdr),
+ new SubscriptionGuard(Set.of("gx00.gw")), Map.of("impl", cfg), "impl",
+ _ -> "tok");
+
+ assertTrue(svc.capabilities().contains(Capability.SELF_PR),
+ "a profile with a git token grants SELF_PR");
+ }
+
+ @Test
+ void capabilitiesExcludeSelfPrWhenNoGitToken() {
+ FakeHerdr herdr = new FakeHerdr();
+ WorkerService svc = service(herdr, List.of("ccs", "ltms-local"), null);
+
+ assertFalse(svc.capabilities().contains(Capability.SELF_PR),
+ "no git token profile → no SELF_PR capability");
+ }
+
+ @Test
+ void effectiveCwdViaSpawnRequestMatchesExistingResolution() {
+ FakeHerdr herdr = new FakeHerdr();
+ WorkerService svc = service(herdr, List.of("ccs", "ltms-local"), null);
+
+ String cwd = svc.effectiveCwd(new SpawnRequest("ltms-local", "/work/proj", "/caller/home"));
+
+ assertEquals("/work/proj", cwd, "effectiveCwd via SpawnRequest must match the three-arg resolution");
+ }
+
+ @Test
+ void profilesViaPeerLauncherMatchesExistingApi() {
+ FakeHerdr herdr = new FakeHerdr();
+ WorkerService svc = multiProfile(herdr);
+
+ assertEquals(Set.of("gx10", "ollama"), svc.profiles(), "profiles() via PeerLauncher must match");
+ }
+
+ @Test
+ void defaultProfileViaPeerLauncherMatches() {
+ FakeHerdr herdr = new FakeHerdr();
+ WorkerService svc = multiProfile(herdr);
+
+ assertEquals("gx10", svc.defaultProfile(), "defaultProfile() via PeerLauncher must match");
+ }
+
+ @Test
+ void stopViaPeerLauncherTearsDownByHandleId() {
+ FakeHerdr herdr = new FakeHerdr();
+ WorkerService svc = service(herdr, List.of("ccs", "ltms-local"), null);
+ PeerHandle handle = svc.spawn(new SpawnRequest(null, null, null));
+
+ svc.stop(handle.id());
+
+ assertTrue(herdr.called("pane.close"), "stop via handle.id() must close the pane");
+ }
}