diff --git a/bridged/src/main/java/dev/ltms/bridged/peer/Capability.java b/bridged/src/main/java/dev/ltms/bridged/peer/Capability.java
index 15974a6..9b21c78 100644
--- a/bridged/src/main/java/dev/ltms/bridged/peer/Capability.java
+++ b/bridged/src/main/java/dev/ltms/bridged/peer/Capability.java
@@ -37,5 +37,22 @@ public enum Capability {
* 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
+ ORPHAN_REAP,
+
+ /**
+ * The peer surfaces the bridge's logical name ({@link SpawnRequest#sessionName()}) in its own
+ * UI at spawn — e.g. Claude Code's {@code -n} display name, which shows in the prompt box, the
+ * {@code /resume} picker, and the terminal title. This is what lets an operator tell the
+ * bridge's worker apart from a user's own session in the same terminal after a restart.
+ */
+ SESSION_NAME,
+
+ /**
+ * The peer can be relaunched onto a prior conversation via that conversation's own session id
+ * ({@link SpawnRequest#resumeSessionId()}) — e.g. Claude Code's {@code -r}, which adopts the id
+ * as the agent's own identity rather than starting a new conversation. A launcher that declares
+ * this mints/resolves that id at spawn and exposes it on the returned
+ * {@link PeerHandle#agentSessionId()}, so a later resume addresses the same conversation.
+ */
+ SESSION_RESUME
}
diff --git a/bridged/src/main/java/dev/ltms/bridged/peer/PeerHandle.java b/bridged/src/main/java/dev/ltms/bridged/peer/PeerHandle.java
index e290451..b5ed481 100644
--- a/bridged/src/main/java/dev/ltms/bridged/peer/PeerHandle.java
+++ b/bridged/src/main/java/dev/ltms/bridged/peer/PeerHandle.java
@@ -41,4 +41,30 @@ public interface PeerHandle {
default String profile() {
return null;
}
+
+ /**
+ * The bridge's logical name for this session, as assigned at spawn
+ * ({@link SpawnRequest#sessionName()}). Stable across restarts and meaningful to an operator,
+ * unlike the transport identifiers above; a launch with no name leaves the peer's display
+ * identity to the launcher to derive.
+ *
+ * @return the bridge-assigned logical session name, or {@code null} if none was assigned
+ */
+ default String sessionName() {
+ return null;
+ }
+
+ /**
+ * The peer's OWN session id — the handle that resumes this conversation later (the id a
+ * later {@link Capability#SESSION_RESUME resume} spawn would pass back). Null when the adapter
+ * cannot determine it — the contract for an adapter that declines
+ * {@link Capability#SESSION_RESUME}; an adapter that declares that capability returns this
+ * non-null for a spawn that requested session identity, because it knows the id before the
+ * peer has written anything.
+ *
+ * @return the peer's own session id, or {@code null} when not determinable
+ */
+ default String agentSessionId() {
+ return null;
+ }
}
diff --git a/bridged/src/main/java/dev/ltms/bridged/peer/SpawnRequest.java b/bridged/src/main/java/dev/ltms/bridged/peer/SpawnRequest.java
index 7aab58a..ce63676 100644
--- a/bridged/src/main/java/dev/ltms/bridged/peer/SpawnRequest.java
+++ b/bridged/src/main/java/dev/ltms/bridged/peer/SpawnRequest.java
@@ -8,6 +8,17 @@ package dev.ltms.bridged.peer;
*
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)."
+ *
+ *
{@code sessionName} and {@code resumeSessionId} carry the session's durable identity (CB-547a):
+ * the bridge's LOGICAL name for the session (stable across restarts, meaningful to an operator)
+ * and the peer's OWN prior session id to resume, respectively. Both are opted in — either
+ * may be null/blank, in which case the launcher derives a display name and mints a fresh session.
*/
-public record SpawnRequest(String profileName, String requestedCwd, String callerCwd) {
+public record SpawnRequest(String profileName, String requestedCwd, String callerCwd,
+ String sessionName, String resumeSessionId) {
+
+ /** Back-compat: a spawn with no session identity (fresh session, launcher-derived name). */
+ public SpawnRequest(String profileName, String requestedCwd, String callerCwd) {
+ this(profileName, requestedCwd, callerCwd, null, null);
+ }
}
diff --git a/bridged/src/main/java/dev/ltms/bridged/worker/ClaudeCodeLauncher.java b/bridged/src/main/java/dev/ltms/bridged/worker/ClaudeCodeLauncher.java
index 5149dcd..1215433 100644
--- a/bridged/src/main/java/dev/ltms/bridged/worker/ClaudeCodeLauncher.java
+++ b/bridged/src/main/java/dev/ltms/bridged/worker/ClaudeCodeLauncher.java
@@ -13,6 +13,7 @@ import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.UUID;
import java.util.function.Function;
import java.util.function.LongSupplier;
@@ -113,13 +114,25 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
/**
* {@inheritDoc}
*
- *
The spawn sequence encodes the subscription boundary: assert the profile's base_url is on
- * the allowlist before any herdr call, then build the worker env with
- * {@code ANTHROPIC_*}, the parity-neutral git-forge grant, and the bridge MCP + reply charter
- * mounted as inline launch flags.
+ *
A legacy spawn with no session identity is a fresh, launcher-derived session — delegate to
+ * the session-aware form with no name and no resume id.
*/
@Override
protected Launch buildLaunch(BridgedConfig.Worker cfg) {
+ return buildLaunch(cfg, null, null);
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ *
The spawn sequence encodes the subscription boundary: assert the profile's base_url is on
+ * the allowlist before any herdr call, then build the worker env with
+ * {@code ANTHROPIC_*}, the parity-neutral git-forge grant, and the bridge MCP + reply charter
+ * mounted as inline launch flags. When the request carries session identity (CB-547a) it is
+ * applied here — see {@link #applySessionIdentity}.
+ */
+ @Override
+ protected Launch buildLaunch(BridgedConfig.Worker cfg, String sessionName, String resumeSessionId) {
// CB-539: a profile may deliberately opt into the subscription (subscription: true) when no
// off-subscription endpoint exists for it — e.g. `sonnet` on `ccs`. That profile gets no
// ANTHROPIC_BASE_URL/AUTH_TOKEN (there is nothing to point them at) and the guard's base_url
@@ -159,7 +172,45 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
putIfPresent(workerEnv, "CLAUDE_CONFIG_DIR", cfg.configDir());
applyGitToken(workerEnv, cfg);
- return new Launch(workerEnv, argvWithModel(argvWithBridge(cfg), cfg));
+ // CB-547a: Claude Code can MINT its own session id, so bridged chooses it — a fresh spawn
+ // gets a UUID we pass as --session-id and return from agentSessionId(), so the resume
+ // handle is known BEFORE the agent has written anything; a resume spawn adopts its prior
+ // id via -r and passes no --session-id (the two conflict). Both are injected before the
+ // 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 no MCP — session flags must be added into a list we own.
+ List argv = mutableArgv(argvWithBridge(cfg));
+ String agentSessionId = applySessionIdentity(argv, sessionName, resumeSessionId);
+ return new Launch(workerEnv, argvWithModel(argv, cfg), agentSessionId);
+ }
+
+ /**
+ * Add the Claude-specific session-identity flags to {@code argv} and return the peer's OWN
+ * session id — the resume handle. A resume request passes the prior id via {@code -r} and
+ * returns that id; a fresh named session mints a new UUID, passes it via {@code --session-id},
+ * and returns the mint. The bridge's logical name rides along as {@code -n} when present. When
+ * no identity is requested (sessionName and resumeSessionId both blank) this adds
+ * nothing and returns {@code null}, keeping the legacy no-identity launch byte-identical.
+ */
+ private static String applySessionIdentity(List argv, String sessionName, String resumeSessionId) {
+ boolean resuming = resumeSessionId != null && !resumeSessionId.isBlank();
+ boolean named = sessionName != null && !sessionName.isBlank();
+ if (!resuming && !named) {
+ return null; // no identity requested — keep the legacy launch byte-identical
+ }
+ if (named) {
+ argv.add("-n");
+ argv.add(sessionName);
+ }
+ if (resuming) {
+ argv.add("-r");
+ argv.add(resumeSessionId);
+ return resumeSessionId;
+ }
+ String minted = UUID.randomUUID().toString();
+ argv.add("--session-id");
+ argv.add(minted);
+ return minted;
}
/**
@@ -230,7 +281,8 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
@Override
public Set capabilities() {
Set caps = EnumSet.of(Capability.MID_TURN_ASK, Capability.WORKTREE,
- Capability.CONTEXT_RESET, Capability.ORPHAN_REAP);
+ Capability.CONTEXT_RESET, Capability.ORPHAN_REAP,
+ Capability.SESSION_NAME, Capability.SESSION_RESUME);
if (hasGitTokenProfile()) {
caps.add(Capability.SELF_PR);
}
diff --git a/bridged/src/main/java/dev/ltms/bridged/worker/CompositePeerLauncher.java b/bridged/src/main/java/dev/ltms/bridged/worker/CompositePeerLauncher.java
index 5e35025..938eb08 100644
--- a/bridged/src/main/java/dev/ltms/bridged/worker/CompositePeerLauncher.java
+++ b/bridged/src/main/java/dev/ltms/bridged/worker/CompositePeerLauncher.java
@@ -168,7 +168,10 @@ public final class CompositePeerLauncher implements PeerLauncher {
continue;
}
- SpawnRequest routedReq = new SpawnRequest(chosen.profile(), req.requestedCwd(), req.callerCwd());
+ // CB-547a: route the chosen profile but keep the caller's session identity — dropping it
+ // here would silently sever the resume handle on every policy-routed spawn.
+ SpawnRequest routedReq = new SpawnRequest(chosen.profile(), req.requestedCwd(), req.callerCwd(),
+ req.sessionName(), req.resumeSessionId());
try {
PeerHandle handle = d.spawn(routedReq);
spawnedBy.put(handle.id(), d);
diff --git a/bridged/src/main/java/dev/ltms/bridged/worker/HerdrPeerLauncher.java b/bridged/src/main/java/dev/ltms/bridged/worker/HerdrPeerLauncher.java
index a3a934e..3edcd4a 100644
--- a/bridged/src/main/java/dev/ltms/bridged/worker/HerdrPeerLauncher.java
+++ b/bridged/src/main/java/dev/ltms/bridged/worker/HerdrPeerLauncher.java
@@ -130,6 +130,21 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
*/
protected abstract Launch buildLaunch(BridgedConfig.Worker cfg);
+ /**
+ * Session-aware variant of {@link #buildLaunch(BridgedConfig.Worker)} (CB-547a). Default
+ * discards the session identity and delegates to the profile-only form, so an adapter that
+ * carries no durable peer session (opencode, say) inherits byte-identical behaviour and needs
+ * no change. An adapter that does (Claude Code) overrides this to mint/resume the id and to
+ * surface it on the returned {@link Launch#agentSessionId()}.
+ *
+ * @param cfg the resolved profile to spawn
+ * @param sessionName the bridge's logical session name, or null/blank for launcher-derived
+ * @param resumeSessionId the peer's own prior session id to resume, or null/blank for fresh
+ */
+ protected Launch buildLaunch(BridgedConfig.Worker cfg, String sessionName, String resumeSessionId) {
+ return buildLaunch(cfg);
+ }
+
/** Direct transport access for peer-specific, non-turn control operations. */
protected final AgentControl agents() {
return agents;
@@ -149,8 +164,18 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
return false;
}
- /** A peer-specific launch: the herdr {@code env} map and {@code argv}. */
- protected record Launch(Map env, List argv) {
+ /**
+ * A peer-specific launch: the herdr {@code env} map and {@code argv}, plus — for an adapter
+ * that carries durable session identity (CB-547a) — the peer's OWN session id
+ * ({@link PeerHandle#agentSessionId()}), known before the peer has written anything. Null for
+ * a launch that carries no identity.
+ */
+ protected record Launch(Map env, List argv, String agentSessionId) {
+
+ /** A launch without a discoverable agent session id (an adapter that carries none). */
+ Launch(Map env, List argv) {
+ this(env, argv, null);
+ }
}
// --- profile surface -----------------------------------------------------------------------
@@ -200,6 +225,10 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
// --- spawn ---------------------------------------------------------------------------------
+ /** A started peer plus the launch's agent-session id (the resume handle, or null). */
+ private record Spawned(Agent agent, String agentSessionId) {
+ }
+
/**
* Spawn a peer. {@code profileName} null/blank → the default profile. The working directory
* (CB-112) is resolved by {@link #resolveCwd}: an explicit {@code requestedCwd}, else the
@@ -208,12 +237,24 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
* The adapter's {@link #buildLaunch} runs before any herdr call.
*/
protected Agent spawnInternal(String profileName, String requestedCwd, String callerCwd) {
+ return spawnInternal(profileName, requestedCwd, callerCwd, null, null).agent();
+ }
+
+ /**
+ * Spawn a peer with session identity (CB-547a). {@code sessionName} and {@code resumeSessionId}
+ * are threaded from the {@link SpawnRequest} into {@link #buildLaunch(BridgedConfig.Worker,
+ * String, String)}, and the launch's resolved agent-session id is returned alongside the agent
+ * so the caller can put it on the {@link PeerHandle}.
+ */
+ protected Spawned spawnInternal(String profileName, String requestedCwd, String callerCwd,
+ String sessionName, String resumeSessionId) {
BridgedConfig.Worker cfg = requireProfile(profileName);
- Launch launch = buildLaunch(cfg);
+ Launch launch = buildLaunch(cfg, sessionName, resumeSessionId);
String cwd = resolveCwd(requestedCwd, cfg, callerCwd);
- return cfg.tabPlacement()
+ Agent agent = cfg.tabPlacement()
? spawnInTab(cfg, launch.env(), launch.argv(), cwd)
: spawnAsPane(cfg, launch.env(), launch.argv(), cwd);
+ return new Spawned(agent, launch.agentSessionId());
}
/**
@@ -230,7 +271,9 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
*/
@Override
public PeerHandle spawn(SpawnRequest req) {
- Agent agent = spawnInternal(req.profileName(), req.requestedCwd(), req.callerCwd());
+ Spawned spawned = spawnInternal(req.profileName(), req.requestedCwd(), req.callerCwd(),
+ req.sessionName(), req.resumeSessionId());
+ Agent agent = spawned.agent();
String paneId = agent.paneId();
if (spawnReadyTimeoutMs > 0) {
waitUntilInjectableOrThrow(paneId);
@@ -238,7 +281,8 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
// CB-519: the handle id is a host-unique UUID; the herdr pane it maps to stays internal.
String id = UUID.randomUUID().toString();
paneByAgentId.put(id, paneId);
- return new WorkerHandle(id, agent.terminalId(), requireProfile(req.profileName()).profile());
+ return new WorkerHandle(id, agent.terminalId(), requireProfile(req.profileName()).profile(),
+ req.sessionName(), spawned.agentSessionId());
}
@Override
@@ -535,8 +579,13 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
+ spawnReadyTimeoutMs + "ms");
}
- /** A concrete {@link PeerHandle} wrapping herdr agent coordinates and the profile that spawned it. */
- private record WorkerHandle(String id, String terminalId, String profile) implements PeerHandle {
+ /**
+ * A concrete {@link PeerHandle} wrapping herdr agent coordinates, the profile that spawned it,
+ * and the session identity the launch resolved (CB-547a): the bridge's logical name and the
+ * peer's own session id, both null when the spawn carried no identity.
+ */
+ private record WorkerHandle(String id, String terminalId, String profile,
+ String sessionName, String agentSessionId) implements PeerHandle {
}
// --- shared helpers ------------------------------------------------------------------------
diff --git a/bridged/src/test/java/dev/ltms/bridged/worker/ClaudeCodeLauncherTest.java b/bridged/src/test/java/dev/ltms/bridged/worker/ClaudeCodeLauncherTest.java
index a6f2d80..24665c7 100644
--- a/bridged/src/test/java/dev/ltms/bridged/worker/ClaudeCodeLauncherTest.java
+++ b/bridged/src/test/java/dev/ltms/bridged/worker/ClaudeCodeLauncherTest.java
@@ -354,6 +354,65 @@ class ClaudeCodeLauncherTest {
assertEquals("/work/proj", cwd, "effectiveCwd via SpawnRequest must match the three-arg resolution");
}
+ // --- CB-547a: durable session identity (mint / resume / no-identity legacy) -----------------
+
+ @Test
+ void freshSpawnMintsASessionIdAndPassesTheName() {
+ FakeHerdr herdr = new FakeHerdr();
+ ClaudeCodeLauncher svc = service(herdr, List.of("ccs", "ltms-local"), null);
+
+ PeerHandle handle = svc.spawn(new SpawnRequest("ltms-local", null, null, "my-session", null));
+
+ List args = spawnedArgs(herdr);
+ int flag = args.indexOf("--session-id");
+ assertTrue(flag >= 0 && flag + 1 < args.size(), "--session-id present: " + args);
+ String minted = args.get(flag + 1);
+ assertDoesNotThrow(() -> UUID.fromString(minted), "--session-id is a valid UUID: " + minted);
+ assertEquals("my-session", args.get(args.indexOf("-n") + 1), "the logical name rides as -n");
+ assertEquals(minted, handle.agentSessionId(),
+ "the resume handle is the minted id, known before the agent has written anything");
+ assertEquals("my-session", handle.sessionName(), "the handle carries the logical name");
+ }
+
+ @Test
+ void resumeSpawnPassesDashRAndNeverASessionId() {
+ FakeHerdr herdr = new FakeHerdr();
+ ClaudeCodeLauncher svc = service(herdr, List.of("ccs", "ltms-local"), null);
+
+ PeerHandle handle = svc.spawn(new SpawnRequest("ltms-local", null, null, "my-session", "cb-resume-1"));
+
+ List args = spawnedArgs(herdr);
+ assertFalse(args.contains("--session-id"), "--session-id must NOT be passed on a resume (conflicts with -r)");
+ assertEquals("cb-resume-1", args.get(args.indexOf("-r") + 1), "-r carries the prior session id");
+ assertEquals("cb-resume-1", handle.agentSessionId(), "a resume adopts the prior id as its own");
+ assertEquals("my-session", handle.sessionName(), "the logical name survives a resume");
+ }
+
+ @Test
+ void noIdentitySpawnKeepsTheLegacyArgvAndCarriesNoSessionHandle() {
+ FakeHerdr herdr = new FakeHerdr();
+ ClaudeCodeLauncher svc = service(herdr, List.of("ccs", "ltms-local"), null);
+
+ PeerHandle handle = svc.spawn(new SpawnRequest("ltms-local", null, null));
+
+ List args = spawnedArgs(herdr);
+ assertFalse(args.contains("--session-id"), "no identity → no --session-id");
+ assertFalse(args.contains("-n"), "no identity → no -n");
+ assertFalse(args.contains("-r"), "no identity → no -r");
+ assertNull(handle.agentSessionId(), "no identity → no resume handle");
+ assertNull(handle.sessionName(), "no identity → no logical name");
+ }
+
+ @Test
+ void capabilitiesIncludeSessionNameAndSessionResume() {
+ FakeHerdr herdr = new FakeHerdr();
+ ClaudeCodeLauncher svc = service(herdr, List.of("ccs", "ltms-local"), null);
+
+ Set caps = svc.capabilities();
+ assertTrue(caps.contains(Capability.SESSION_NAME), "Claude Code surfaces the bridge's logical name (-n)");
+ assertTrue(caps.contains(Capability.SESSION_RESUME), "Claude Code can relaunch onto a prior conversation (-r)");
+ }
+
@Test
void profilesViaPeerLauncherMatchesExistingApi() {
FakeHerdr herdr = new FakeHerdr();