From d146a01422e6ae1048c7ab285fe1760bc640a69b Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Thu, 13 Aug 2026 16:43:47 +0200 Subject: [PATCH] CB-547a: peer-neutral session-identity contract + Claude Code adapter --- .../dev/ltms/bridged/peer/Capability.java | 19 +++++- .../dev/ltms/bridged/peer/PeerHandle.java | 26 ++++++++ .../dev/ltms/bridged/peer/SpawnRequest.java | 13 +++- .../bridged/worker/ClaudeCodeLauncher.java | 64 ++++++++++++++++-- .../bridged/worker/CompositePeerLauncher.java | 5 +- .../bridged/worker/HerdrPeerLauncher.java | 65 ++++++++++++++++--- .../worker/ClaudeCodeLauncherTest.java | 59 +++++++++++++++++ 7 files changed, 234 insertions(+), 17 deletions(-) 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(); -- 2.52.0