bc13b8e92c
Two leads now work as peers rather than one primary plus workers. The arc:
CB-530/531 lead identity: `leaders:` names panes, `leadScan:` discovers them by
tab label (LeadTabScanner, TTL-cached, worker spaces excluded).
CB-532 leads can message each other AND be answered. Principal.leader now
carries its terminal, so ownsSession() can be true for a lead; the
"and you must be a worker" conjunct beside it protected nothing.
Retires `primary:` — reply nudges follow the delegating lead, a
binding recorded at bridge_send where both halves are known.
CB-533 ClaudeCodeLauncher passes --model. argv is usually a wrapper
(`ccs <profile>`) that re-exports its own model family, so
ANTHROPIC_MODEL alone was silently overruled.
CB-534 a lead is deliverable. The CB-113 readiness gate only opened for
terminals in WorkerPresence, which only workers ever enter, so every
lead->lead send waited out the ~60s grace and failed having never
been typed. The gate guards a *spawned* peer's boot window; a lead
is never spawned.
CB-535 bridge_list returns `leads` alongside `workers`, with `self` on the
caller's row. An empty worker roster no longer reads as "no peers".
CB-536 CLAUDE.md: lead<->lead is coordinate-only, never sideways delegation.
Propagated byte-identically to wiki/7-Use-Cases.md.
MIXED PROVENANCE — recorded deliberately rather than hidden. This tree also carries
in-progress CB-537 (context separation) authored by the peer lead gpt-sol-5.6 and
its worker: Capability.CONTEXT_RESET, SessionManager.clearAfterTurn, and the
Injector/TurnListener/CompletionResolver/launcher changes around it. That work was
done in this shared working tree rather than a worktree, and is entangled with the
above in BridgedConfig.java, Bridged.java and ClaudeCodeLauncher.java, so neither
lead could stage its own half without sweeping in the other's. Committing the whole
green state is the honest resolution; the peer branches from here.
Note for whoever picks CB-537 up: the design in this commit is SUPERSEDED. Both
leads agreed to replace the global `clearAfterTurn` boolean with per-delivery
policy (inherit|fresh|thread) applied PRE-delivery, because a post-turn reset races
by construction — Injector.onStatus clears awaitingCompletion and dequeues the next
message in the same tick. `fresh` is also a correctness guarantee, so an adapter
without a reset capability must refuse it rather than log a no-op.
mvn clean install: Tests run: 464, Failures: 0, Errors: 0, Skipped: 0. BUILD SUCCESS.
287 lines
18 KiB
YAML
287 lines
18 KiB
YAML
# bridged configuration (example). Copy to bridged.yaml and adjust.
|
|
#
|
|
# bridged is the sole gateway between primary/worker Claude sessions and herdr.
|
|
# It is NOT a Claude process and must never carry ANTHROPIC_BASE_URL.
|
|
|
|
# REST + MCP listen address. Keep it on loopback unless you also switch auth.mode to `token`
|
|
# below — bridged REFUSES TO START on a non-loopback bind under loopback-trust (see auth).
|
|
bind:
|
|
host: 127.0.0.1
|
|
port: 8765
|
|
|
|
# API authentication (CB-501). Governs how a caller that is NOT an on-host worker pane proves it
|
|
# is the primary. Worker identity never depends on this: a loopback peer PID that maps to a herdr
|
|
# pane is unforgeable and is always honoured, so turning auth on cannot lock the fleet out.
|
|
#
|
|
# mode: loopback-trust → DEFAULT, and the historical behaviour: any loopback caller that is not
|
|
# a worker is the primary, no credential needed. Sound ONLY because the
|
|
# OS refuses remote connections to a loopback socket.
|
|
# mode: token → such a caller must send `Authorization: Bearer <token>`; without it it
|
|
# is anonymous and authorized for nothing. REQUIRED for a non-loopback
|
|
# bind — the daemon fails fast otherwise, because "unauthenticated ⇒
|
|
# primary" on a reachable port would hand spawn/stop/send to anyone.
|
|
# tokenEnv → host env var holding the token (never the literal value). Default
|
|
# BRIDGED_API_TOKEN. Read only in token mode; empty ⇒ startup fails.
|
|
#
|
|
# TLS is deliberately NOT terminated in the daemon (CB-501 D3): run a reverse proxy in front and
|
|
# let it own certificate lifecycle, e.g.
|
|
# location / { proxy_pass http://127.0.0.1:8765; proxy_set_header Authorization $http_authorization; }
|
|
# The broker link gets TLS from its own URI (amqps://…) — see `broker` below.
|
|
# auth:
|
|
# mode: token
|
|
# tokenEnv: BRIDGED_API_TOKEN
|
|
|
|
# Optional pinned primary terminal (CB-307). Names the herdr pane the PRIMARY itself runs in:
|
|
# a caller whose connection maps to this pane resolves as the primary (no credential needed —
|
|
# the pane mapping is as unforgeable as a worker's), and reply nudges are pushed to it.
|
|
# REQUIRED when the primary runs inside a herdr pane — without it the pane match reads the
|
|
# primary as a worker and refuses spawn/send/stop. Get the id from bridge_whoami; re-pin if
|
|
# the primary moves panes.
|
|
# primary:
|
|
# terminal: term_0123456789abcd
|
|
# pushReminders: 5 # max nudges before giving up (default 5)
|
|
# pushBackoffMs: 15000 # delay between nudges (default 15000)
|
|
|
|
# CB-530: MORE THAN ONE LEAD. `primary:` above is singular by construction — every other pane
|
|
# resolves as a worker — which is right for one lead driving a fleet and wrong the moment two leads
|
|
# (say a Claude lead and an opencode lead) work as peers: the second is silently demoted and refused
|
|
# every orchestration call. List each lead's pane here and all of them resolve as leads.
|
|
#
|
|
# terminal → the ONLY field identity depends on; get it from that session's bridge_whoami
|
|
# kind/model → descriptive; they document what runs in the pane and are echoed by bridge_whoami
|
|
#
|
|
# A lead is never spawned — it pre-exists, which is exactly why it must be named rather than created.
|
|
# `bridge_whoami` reports `{"role":"primary","leader":"<name>"}`; role stays "primary" because a lead
|
|
# IS a primary for authorization, so nothing that keys on the role breaks.
|
|
#
|
|
# KEEP `primary:` when adding leads: it still addresses the CB-307 push loop, which needs a single
|
|
# destination for its nudges. If both name the same terminal, the `leaders:` entry wins.
|
|
# leaders:
|
|
# opus-5.0:
|
|
# terminal: term_0123456789abcd
|
|
# kind: claude
|
|
# gpt-sol-5.6:
|
|
# terminal: term_fedcba9876543
|
|
# kind: opencode
|
|
# model: openai/gpt-5.6-terra
|
|
|
|
# CB-531: FIND LEADS BY TAB NAME instead of pasting terminal ids. `leaders:` above needs an id that
|
|
# only exists once the session is running, so adding a lead is: open a tab, start the agent, ask it
|
|
# bridge_whoami, edit this file, restart the daemon. This block replaces all of that with a naming
|
|
# convention — label the tab `lead: <name>` when you open it and the pane is recognised on the next
|
|
# rescan, with no config edit and no restart. Reopen the tab later and the id changes; the label
|
|
# does not.
|
|
#
|
|
# bridged NEVER writes these labels. It renames worker tabs (see `tabLabel` below) but reads lead
|
|
# tabs read-only, so what is in the tab bar is always what you typed. Two things keep the convention
|
|
# from being a way to claim leadership: the configured worker spaces are excluded from the scan, so
|
|
# nothing bridged places can land in a matching tab; and startup REFUSES a `tabPrefix` that any
|
|
# worker `tabLabel` also matches, so the two namespaces cannot overlap by accident.
|
|
#
|
|
# Opt-in on purpose — this widens who resolves as a lead, so upgrading the daemon must never switch
|
|
# it on for you. Absent block = leads come only from `leaders:`/`primary:`, exactly as before.
|
|
# leadScan:
|
|
# tabPrefix: "lead:" # `lead: opus-5.0` ⇒ a lead named opus-5.0 (case-insensitive; default "lead:")
|
|
# intervalSeconds: 10 # rescan cadence, and the worst case before a new tab is recognised
|
|
|
|
# herdr Unix socket. Omit to use the client default
|
|
# (${HERDR_SOCKET_PATH:-~/.config/herdr/herdr.sock}).
|
|
herdrSocket: ~/.config/herdr/herdr.sock
|
|
|
|
# How worker sessions are spawned. Define one or more named profiles (backends) under
|
|
# `workers`; each key is the profile name (also the ccs profile). `defaultWorker` picks
|
|
# which one a no-argument spawn uses (bridge_spawn with no profile / POST /workers).
|
|
#
|
|
# Shared knobs (placement/workspace/tabLabel) can be repeated per profile; they usually match.
|
|
# placement: tab → each worker lands in its OWN tab in a dedicated worker space (default).
|
|
# Use `pane` for the legacy behaviour (split the focused tab).
|
|
# mcpUrl → bridged mounts the bridge MCP (--mcp-config, inline) + reply charter
|
|
# (--append-system-prompt) as launch flags; nothing is written to the profile.
|
|
# 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
|
|
# cwd on an MCP spawn, else the daemon's cwd — never $HOME. See
|
|
# docs/Worker-Startup-and-Trust.md.
|
|
# configDir → CLAUDE_CONFIG_DIR for the worker, so it inherits that profile's
|
|
# skills/MCP/hooks. Omit to leave the worker on the host default.
|
|
# parityOverlay → repo-relative paths copied primary→worktree so a worker in a provisioned
|
|
# worktree sees the same local config (CB-301-ext). Omit for the default set:
|
|
# [.claude/settings.local.json, .env, .envrc].
|
|
#
|
|
# Do NOT add .mcp.json (CB-525). A worker's tools are whatever its launcher
|
|
# mounts — the bridge, and nothing else. Replicating the primary's MCP config
|
|
# handed a worker the primary's IDE servers, which are bound to the primary's
|
|
# checkout, so its navigation returned paths OUTSIDE its own worktree: one
|
|
# worker made all 59 of its edits in the primary tree while compiling its
|
|
# worktree, and every build it ran was of code that did not contain them.
|
|
# bridged neutralizes a provisioned worktree's .mcp.json for this reason;
|
|
# listing it here would copy the primary's back over that.
|
|
# gitTokenEnv → host env var holding the git-forge API token. When set, its value is injected
|
|
# as GITEA_TOKEN so the worker can open its OWN PR at checkpoint (CB-302).
|
|
# Opt-in by design — omit and the worker gets no PR-create grant (push over
|
|
# SSH is unaffected). The token value itself is never stored in this file.
|
|
# gitHostEnv → host env var holding the forge host (default GITEA_HOST). Injected as
|
|
# GITEA_HOST *only* alongside a resolved gitTokenEnv.
|
|
# env → extra environment for this profile's workers, as a literal key/value map
|
|
# (CB-511). Use it to give workers a toolchain.
|
|
#
|
|
# A worker's environment does NOT come from your shell. bridged hands herdr an
|
|
# explicit env map and herdr merges it into ITS OWN process env — so before
|
|
# CB-511 a worker inherited whatever PATH the herdr server happened to be
|
|
# started with, which on a long-lived herdr can predate your toolchain entirely
|
|
# and leave workers unable to run `mvn` or `java` at all.
|
|
# bridged now propagates ITS OWN PATH to every worker by default; set `env:`
|
|
# only to override that or add more (JAVA_HOME, …). Since the default is the
|
|
# daemon's PATH, make sure the daemon is started with a good one — see the PATH
|
|
# lines in deploy/dev.ltms.bridged.plist and deploy/bridged.service.
|
|
#
|
|
# Adapter-owned variables always win over `env:`: ANTHROPIC_BASE_URL and the
|
|
# rest of the ANTHROPIC_*/CLAUDE_* wiring are applied after it, so an `env:`
|
|
# entry cannot repoint a worker past the SubscriptionGuard — which is checked
|
|
# against `baseUrl` alone.
|
|
# Put `defaultMode: "auto"` in each ccs profile so the worker runs autonomously.
|
|
workers:
|
|
gx10: # ccs profile name (NOT a hostname)
|
|
kind: claude-code # which adapter spawns this profile (default; may omit)
|
|
baseUrl: http://gx01.gw:8000 # the vLLM host this profile targets (gx00.gw / gx01.gw)
|
|
model: coder
|
|
placement: tab
|
|
workspace: bridged-workers
|
|
tabLabel: "worker: {profile} #{n}" # {profile}/{model}/{n} substituted; {n} keeps sibling tabs distinct
|
|
mcpUrl: http://127.0.0.1:8765/mcp
|
|
tokenEnv: BRIDGED_WORKER_TOKEN
|
|
argv: ["ccs", "gx10"]
|
|
weight: 0.5 # relative selection weight for placement: weighted
|
|
maxLoad: 2 # max live workers on this profile (omit for unlimited)
|
|
# gitTokenEnv: GITEA_TOKEN # opt-in: let this profile's workers open their own PR (CB-302)
|
|
# gitHostEnv: GITEA_HOST # defaults to GITEA_HOST; injected only with gitTokenEnv
|
|
# configDir: /Users/me/.ccs/instances/gx10 # CLAUDE_CONFIG_DIR — inherit that profile's skills/MCP
|
|
# cwd: /Users/me/src/myrepo # pin the working dir; omit to inherit the primary's
|
|
# parityOverlay: [".claude/settings.local.json", ".env", ".envrc"] # never add .mcp.json — see above
|
|
gx11: # a second backend, so `placement: weighted` has a choice
|
|
baseUrl: http://gx01.gw:8000 # self-hosted; ccs handles the model + token
|
|
placement: tab
|
|
workspace: bridged-workers
|
|
tabLabel: "worker: {profile} #{n}"
|
|
mcpUrl: http://127.0.0.1:8765/mcp
|
|
argv: ["ccs", "gx11"]
|
|
weight: 0.5
|
|
maxLoad: 2
|
|
# Pin an auto-compact window BELOW the served model's context ceiling. The global
|
|
# ~/.claude/settings.json value is shared by every ccs instance and the primary, so the
|
|
# per-profile override belongs here. Equal to the ceiling means auto-compact never fires
|
|
# before the server rejects the prompt, which kills a worker mid-turn (CB-523).
|
|
env:
|
|
CLAUDE_CODE_AUTO_COMPACT_WINDOW: "280000"
|
|
# CB-402: a second coding-agent kind, proving the PeerLauncher SPI is provider-neutral.
|
|
# opencode is provider-agnostic and uses NONE of Claude's private seams: no ANTHROPIC_BASE_URL /
|
|
# SubscriptionGuard (so it needs no `guard` host entry), no --mcp-config / --append-system-prompt.
|
|
# The bridge MCP + reply charter mount via a generated OPENCODE_CONFIG file, and the model is a
|
|
# `provider/model` selector. Placement, tabs, cwd, and the readiness gate are shared with Claude.
|
|
#
|
|
# Dogfood-verified 2026-07-29 against opencode 1.18.5 (spawn → readiness gate → bridge_send →
|
|
# structured bridge_reply → teardown). The `opencode/*-free` models run on opencode's own gateway
|
|
# and need NO credentials — check `opencode models` for the current free list, since the names
|
|
# change. That also makes the worker off-subscription by construction.
|
|
# opencode-free:
|
|
# kind: opencode
|
|
# model: opencode/north-mini-code-free # `provider/model` selector, injected as `-m`
|
|
# placement: tab
|
|
# workspace: bridged-workers
|
|
# tabLabel: "opencode: {profile} #{n}"
|
|
# mcpUrl: http://127.0.0.1:8765/mcp
|
|
# argv: ["opencode"]
|
|
#
|
|
# CB-508: point an opencode profile at your OWN OpenAI-compatible endpoint (local vLLM, llama.cpp,
|
|
# LM Studio, TGI…) instead of opencode's gateway. Setting `baseUrl` on a `kind: opencode` profile
|
|
# makes the bridge emit a custom `provider` block into the generated opencode.json — opencode has
|
|
# no ANTHROPIC_BASE_URL seam, so this is how the endpoint is pinned.
|
|
# baseUrl → a bare host:port gets `/v1` appended (where these servers mount the API); a URL that
|
|
# already has a path is used verbatim, so a custom mount point still works.
|
|
# model → MUST be "<provider>/<model>". The provider half names the generated block; the model
|
|
# half must match an id the server reports at /v1/models. One field drives both the
|
|
# declaration and the `-m` flag, so they cannot drift apart. A bare model name with a
|
|
# baseUrl set is rejected at spawn rather than silently using the default gateway.
|
|
# tokenEnv → optional; its value becomes the provider apiKey. Most local servers ignore the key,
|
|
# so a placeholder is used when unset (the AI SDK still requires a non-empty one).
|
|
# NOTE: no `guard` entry is needed even with a baseUrl set. The SubscriptionGuard exists to stop a
|
|
# worker borrowing the primary's Anthropic subscription, and an opencode process has no Anthropic
|
|
# credential path at all.
|
|
# opencode-local:
|
|
# kind: opencode
|
|
# baseUrl: http://127.0.0.1:8000
|
|
# model: local-vllm/deepseek-v4-flash
|
|
# placement: tab
|
|
# workspace: bridged-workers
|
|
# tabLabel: "opencode: {profile} #{n}"
|
|
# mcpUrl: http://127.0.0.1:8765/mcp
|
|
# argv: ["opencode"]
|
|
# How an unqualified spawn chooses a profile: fixed (default, reproduces pre-CB-518 behaviour),
|
|
# round-robin, or weighted. Omitting this key is a strict no-op for existing configs.
|
|
placement: weighted
|
|
defaultWorker: gx10
|
|
|
|
# Subscription boundary. A worker's base_url host MUST be one of these; the primary
|
|
# must carry none. Every profile above must have its host listed here.
|
|
guard:
|
|
offSubscriptionHosts:
|
|
- gx00.gw
|
|
- gx01.gw
|
|
|
|
# Spawn-readiness gate (CB-306). The launcher blocks until the worker's herdr status is
|
|
# injectable (IDLE/BLOCKED/DONE) or the timeout elapses. 0 disables the gate.
|
|
# NOTE: keys are camelCase — config is bound by plain Jackson with no naming strategy and
|
|
# unknown keys are ignored, so a snake_case key would be silently dropped (default kept).
|
|
# spawnReadyTimeoutMs: 20000
|
|
# spawnReadyPollMs: 300
|
|
|
|
# Worktree provisioning root (CB-301-ext). Where per-worker git worktrees are checked out so
|
|
# each worker owns an isolated branch instead of sharing the primary's tree. Omit to default
|
|
# to a sibling directory of the repo root.
|
|
# worktreeRoot: /Users/me/src/.bridged-worktrees
|
|
|
|
# Session lifecycle limits (CB-303). All knobs are opt-in; omit or set to null to keep
|
|
# the feature disabled. By default the daemon never reaps, caps, or drains sessions.
|
|
# idleTtlSeconds → reap READY/DONE sessions idle longer than this (never BUSY/SPAWNING)
|
|
# contextCap → force-release a session after this many delegated turns
|
|
# drainTimeoutSeconds → seconds to wait for BUSY sessions on shutdown before forced teardown
|
|
# lifecycle:
|
|
# idleTtlSeconds: 300
|
|
# contextCap: 10
|
|
# drainTimeoutSeconds: 5
|
|
|
|
# Durable reply delivery (CB-307 Stage 2). OMIT this block entirely to keep the default
|
|
# in-memory, soft-state reply inbox (late worker replies are held only until a daemon bounce).
|
|
# Set a broker uri to swap in the AMQP-backed inbox: worker replies with no open send are held
|
|
# on a durable per-target queue (agent.<target>.inbox) and survive a restart — the broker
|
|
# redelivers anything the primary had not yet drained. Production default is LavinMQ; a stock
|
|
# RabbitMQ speaks the same AMQP 0-9-1, so it is a URI-only swap.
|
|
# uri → AMQP connection URI. No trailing slash ⇒ the default vhost "/"; an empty path ("/")
|
|
# is vhost "" and will NOT connect. Encode a named vhost as .../%2Fmyvhost.
|
|
# broker:
|
|
# uri: amqp://guest:guest@127.0.0.1:5672
|
|
|
|
# Active push-to-primary (CB-307 Stage 3). When a worker reply lands with no open bridge_send,
|
|
# the ReplyPushLoop injects a *drain nudge* (never the payload) into the primary's own herdr
|
|
# pane — status-gated (only when injectable, never mid-turn) and bounded. Ack = drain: the loop
|
|
# stops as soon as the primary's inbox is empty.
|
|
# terminal → pin the primary's herdr terminal id. Omit to learn it from the connection on
|
|
# the first orchestration-side MCP call (the normal case). An off-host or
|
|
# non-herdr primary leaves this unresolved → the loop is a no-op and delivery
|
|
# degrades to pull; the reply is still never lost.
|
|
#
|
|
# REQUIRED (CB-522) if the primary itself runs inside a herdr pane. Caller
|
|
# identity resolves a loopback PID to its herdr pane, and PaneLocator scans
|
|
# EVERY pane — not just bridged-spawned ones — so such a primary is otherwise
|
|
# classified as a WORKER and refused SPAWN/SEND/STOP. That failure is
|
|
# self-locking: the learned terminal is populated by the very orchestration
|
|
# calls being refused, so only this pinned value can break the cycle. Read the
|
|
# id off bridge_whoami (it reports the current terminal even while
|
|
# misclassified) and re-pin whenever the primary moves panes.
|
|
# pushReminders → max nudges before giving up (default 5)
|
|
# pushBackoffMs → delay between nudges in ms (default 15000)
|
|
# primary:
|
|
# terminal: term_65619bd6174568
|
|
# pushReminders: 5
|
|
# pushBackoffMs: 15000
|