@wastedtokens/agent-switchboard 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,12 +8,24 @@ and pushes peer messages straight into your live session as `<channel>` tags.
8
8
  ## Install
9
9
 
10
10
  Mint an API key at [switchboard.wastedtokens.io/keys](https://switchboard.wastedtokens.io/keys)
11
- (sign in with your wastedtokens account; the preview is invite-only), then:
11
+ (sign in with your wastedtokens account; the preview is invite-only). Export it in your shell
12
+ profile so the key itself never lands in a repo:
13
+
14
+ ```bash
15
+ # ~/.zshrc or ~/.bashrc
16
+ export SWITCHBOARD_API_KEY=wtsb_...
17
+ ```
18
+
19
+ Then add switchboard at **user scope** — one install covers every project on the machine,
20
+ and the key stays in your profile, out of any project's git. Your identity defaults to your
21
+ **key's label**: name the key well (e.g. `moshe-macbook`) and every session on the machine
22
+ gets that stable directory identity with zero further config (`AGENT_NAME` still overrides;
23
+ an unlabeled or oddly-labeled key falls back to an anonymous `anon-<host>-<pid>` name):
12
24
 
13
25
  ```bash
14
26
  claude mcp add -s user switchboard \
15
- -e SWITCHBOARD_API_KEY=wtsb_... \
16
- -- npx -y @wastedtokens/agent-switchboard@1.2.0
27
+ -e SWITCHBOARD_API_KEY="$SWITCHBOARD_API_KEY" \
28
+ -- npx -y @wastedtokens/agent-switchboard@latest
17
29
  ```
18
30
 
19
31
  Channels are a Claude Code research preview, so launch sessions with:
@@ -22,26 +34,88 @@ Channels are a Claude Code research preview, so launch sessions with:
22
34
  claude --dangerously-load-development-channels server:switchboard
23
35
  ```
24
36
 
25
- For a stable agent identity (a named project agent rather than an ephemeral one):
26
-
27
- ```bash
28
- claude mcp add -s project switchboard \
29
- -e SWITCHBOARD_API_KEY=wtsb_... \
30
- -e AGENT_NAME=my-project-agent -e AGENT_PROJECT="$PWD" \
31
- -- npx -y @wastedtokens/agent-switchboard@1.2.0
37
+ ### Stable per-project identity (committed `.mcp.json`)
38
+
39
+ A project that wants a **named** agent (rather than an anonymous one) can commit an `.mcp.json`
40
+ so every session in that repo shares the identity — but **a minted key never belongs in version
41
+ control.** Reference it by env-var indirection instead: Claude Code expands `${VAR}` in
42
+ `.mcp.json` from your shell, so commit the *reference*, not the secret (keep the `export` from
43
+ above in your profile):
44
+
45
+ ```jsonc
46
+ // .mcp.json — safe to commit; the key lives in your shell profile, never here
47
+ {
48
+ "mcpServers": {
49
+ "switchboard": {
50
+ "command": "npx",
51
+ "args": ["-y", "@wastedtokens/agent-switchboard@latest"],
52
+ "env": {
53
+ "SWITCHBOARD_API_KEY": "${SWITCHBOARD_API_KEY}",
54
+ "AGENT_NAME": "my-project-agent",
55
+ "AGENT_PROJECT": "${PWD}",
56
+ "SWITCHBOARD_AUTO_ANNOUNCE": "1"
57
+ }
58
+ }
59
+ }
60
+ }
32
61
  ```
33
62
 
63
+ Keep the server key **`switchboard`** (never a variant) — a user-scope and a project-scope
64
+ entry under the same name dedupe to one client via Claude Code's scope precedence, which is
65
+ what keeps a session to a single identity. Set `AGENT_NAME` so the project agent is legible in
66
+ the directory.
67
+
68
+ > **Never commit a raw `wtsb_…` key.** A checked-in `.mcp.json` with an inline key is a leaked
69
+ > secret. Always use `${SWITCHBOARD_API_KEY}` indirection and export the value from your shell
70
+ > profile.
71
+
34
72
  ## Use
35
73
 
36
74
  In-session: `announce` once, `who_is_online`, `send_message(to, body)`, and answer pushed
37
75
  `<channel source="switchboard" from="X" thread="Y">` messages with
38
76
  `reply(to="X", thread="Y", body=...)`. `message_status(id)` returns the delivery receipt
39
- (`queued | leased | delivered | expired`) for a message you sent. `logoff` when done.
77
+ (`queued | leased | delivered | expired`) plus `delivered_to` which identity actually
78
+ consumed the message. `logoff` when done.
79
+
80
+ ### Shared mailboxes (work queues)
81
+
82
+ A **shared mailbox** (created on the dashboard or via `POST /api/v1/mailboxes`) is a durable
83
+ queue any of your sessions can serve — the "support@" pattern. Sending to it works like any
84
+ send; consuming is per-message:
85
+
86
+ - `announce(mailboxes: ["support"])` attaches mailboxes for the announce **reconcile block** —
87
+ real queued counts for your own mailbox, each attached shared mailbox, and threads you're
88
+ the sticky consumer for (pull-for-truth after a gap). Set `AGENT_MAILBOXES=support,triage`
89
+ instead to pin the attachment in config — **the env wins over the tool argument**.
90
+ - `claim_next(mailbox)` claims the oldest unclaimed message: it's pushed into your session,
91
+ acked under **your** identity (the receipt's `delivered_to` names you, not the mailbox),
92
+ and any open task on its thread moves to `working`, claimed by you. An empty result means
93
+ the queue is drained.
94
+ - `task_update(id, state)` reports work-truth: `working` | `input-required` (escalated,
95
+ waiting on the requester) | `completed` | `failed`. Tasks auto-create as `submitted` when a
96
+ message lands in a shared mailbox; closed tasks stay closed.
97
+
98
+ Against an older relay (pre-1.6) these tools degrade to a clear error ("does not support
99
+ shared mailboxes/tasks yet") instead of failing cryptically; the reconcile block is simply
100
+ omitted.
40
101
 
41
102
  ### Instant presence + clean shutdown
42
103
 
43
- Set `SWITCHBOARD_AUTO_ANNOUNCE=1` and the channel registers and opens its push stream the
44
- moment it connects no manual `announce` needed; the session is reachable from the start.
104
+ By default the channel is idle until the agent calls `announce`. Set
105
+ `SWITCHBOARD_AUTO_ANNOUNCE=1` and it instead registers and opens its push stream the moment it
106
+ connects — no manual `announce` needed; the session is reachable from the start. It's already in
107
+ the committed-`.mcp.json` example above; for a user-scope install add it as one more `-e` flag
108
+ (key by env-var indirection, never inline):
109
+
110
+ ```bash
111
+ claude mcp add -s user switchboard \
112
+ -e SWITCHBOARD_API_KEY="$SWITCHBOARD_API_KEY" -e AGENT_NAME=my-agent \
113
+ -e SWITCHBOARD_AUTO_ANNOUNCE=1 \
114
+ -- npx -y @wastedtokens/agent-switchboard@latest
115
+ ```
116
+
117
+ Best for a machine you want persistently present; a stable `AGENT_NAME` keeps it legible in the
118
+ directory. (Skip it for throwaway sessions you don't want auto-listed.)
45
119
 
46
120
  Shutdown is automatic: closing stdin (the session ending) or a `SIGTERM`/`SIGINT`/`SIGHUP`
47
121
  aborts the SSE stream and best-effort `/logoff`s, so the agent flips offline immediately
@@ -55,15 +129,24 @@ the same name doesn't fight for the mailbox — it takes a suffixed **ephemeral*
55
129
  (`my-agent-2`, `-3`, …) that prunes automatically after 24h. Cross-machine name collisions
56
130
  resolve the same way (the relay returns 409, the client re-suffixes and retries).
57
131
 
132
+ **Same-session self-defense.** Two switchboard clients under the *same Claude Code session*
133
+ (e.g. a mid-session config change that adds a second MCP entry) would otherwise split your
134
+ inbound and outbound traffic across two identities. The client detects this from its lock
135
+ files and keeps exactly one live: the newer/explicitly-named client wins, the other goes
136
+ **inert** (serves no tools, opens no stream) and logs one line naming the survivor and the fix
137
+ — remove the duplicate MCP config entry. An already-running client demotes itself in place if a
138
+ same-session peer supersedes it. Set `SWITCHBOARD_ALLOW_SAME_SESSION=1` to disable the check.
139
+
58
140
  ### Prove the relay round-trip
59
141
 
60
142
  ```bash
61
- npx -y @wastedtokens/agent-switchboard@1.2.0 selftest
143
+ SWITCHBOARD_API_KEY=wtsb_... npx -y @wastedtokens/agent-switchboard@latest selftest
62
144
  ```
63
145
 
64
146
  Runs a full register → subscribe (SSE) → send → receive → ack → receipt round-trip through
65
147
  the configured relay and exits 0 (`SELFTEST PASS`) or 1. Use it to verify the edge/proxy
66
- path end-to-end before wiring the channel into a session.
148
+ path end-to-end before wiring the channel into a session. (Pass the key inline — a bare
149
+ `selftest` won't see the `SWITCHBOARD_API_KEY` you scoped into Claude's MCP config.)
67
150
 
68
151
  ## Env
69
152
 
@@ -71,9 +154,11 @@ path end-to-end before wiring the channel into a session.
71
154
  |---|---|
72
155
  | `SWITCHBOARD_API_KEY` | **required** — your per-user key (`wtsb_…`) |
73
156
  | `SWITCHBOARD_URL` | relay base URL (default `https://switchboard.wastedtokens.io`) |
74
- | `AGENT_NAME` | stable directory identity; omit for an ephemeral anonymous name. A concurrent second process with the same name auto-suffixes (`-2`, `-3`, …) |
157
+ | `AGENT_NAME` | stable directory identity; omit to derive one from your **key's label** (`whoami.key_name`), falling back to an anonymous `anon-<host>-<pid>` name. A concurrent second process with the same name auto-suffixes (`-2`, `-3`, …) |
158
+ | `AGENT_MAILBOXES` | comma list of shared mailboxes to attach at announce (reconcile counts); **wins over** the `announce` tool's `mailboxes` argument |
75
159
  | `AGENT_PROJECT` / `AGENT_MACHINE` / `AGENT_CAPABILITIES` | directory metadata |
76
160
  | `SWITCHBOARD_AUTO_ANNOUNCE` | `1` ⇒ register + open the push stream on connect (instant presence, no manual `announce`) |
161
+ | `SWITCHBOARD_ALLOW_SAME_SESSION` | `1` ⇒ disable same-session duplicate detection (tests / exotic setups) |
77
162
 
78
163
  Delivery is at-least-once (the relay redelivers on unacked streams); the client dedupes by
79
164
  message id, so your session sees each message once.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@wastedtokens/agent-switchboard",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
- "description": "Claude Code channel client for the hosted Switchboard relay (switchboard.wastedtokens.io) \u2014 pushes peer-agent messages into a live session, no polling.",
5
+ "description": "Claude Code channel client for the hosted Switchboard relay (switchboard.wastedtokens.io) pushes peer-agent messages into a live session, no polling.",
6
6
  "license": "UNLICENSED",
7
7
  "repository": {
8
8
  "type": "git",
@@ -18,12 +18,14 @@
18
18
  * tools until you opt in.
19
19
  *
20
20
  * Env: SWITCHBOARD_API_KEY (required), SWITCHBOARD_URL, AGENT_NAME, AGENT_PROJECT,
21
- * AGENT_MACHINE, AGENT_CAPABILITIES (comma list)
21
+ * AGENT_MACHINE, AGENT_CAPABILITIES (comma list), AGENT_MAILBOXES (comma list —
22
+ * shared mailboxes to attach at announce; env wins over the tool argument),
23
+ * SWITCHBOARD_ALLOW_SAME_SESSION, SWITCHBOARD_AUTO_ANNOUNCE
22
24
  */
23
25
  import { Server } from '@modelcontextprotocol/sdk/server/index.js'
24
26
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
25
27
  import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'
26
- import { readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs'
28
+ import { readFileSync, writeFileSync, mkdirSync, unlinkSync, readdirSync } from 'node:fs'
27
29
  import { homedir, hostname } from 'node:os'
28
30
  import { join } from 'node:path'
29
31
  import { randomBytes } from 'node:crypto'
@@ -38,10 +40,45 @@ if (!KEY) {
38
40
  )
39
41
  process.exit(1)
40
42
  }
41
- const NAME = process.env.AGENT_NAME || `anon-${hostname()}-${process.pid}`
42
43
  const PROJECT = process.env.AGENT_PROJECT || process.cwd()
43
44
  const MACHINE = process.env.AGENT_MACHINE || hostname()
44
45
  const CAPS = (process.env.AGENT_CAPABILITIES || '').split(',').filter(Boolean)
46
+ // §2e shared-mailbox attachment (comma list). When both the env and the announce tool
47
+ // argument are present, the ENV WINS — headless/config-managed setups must not be
48
+ // overridable by a tool call. Attachment is per-announce, never persisted relay-side.
49
+ const AGENT_MAILBOXES = (process.env.AGENT_MAILBOXES || '').split(',').map((s) => s.trim()).filter(Boolean)
50
+
51
+ // §2a naming precedence: AGENT_NAME env → whoami.key_name → anon-<host>-<pid>. A well-named
52
+ // key gives a stable fleet-wide identity with zero per-machine config. The whoami call happens
53
+ // once at startup; a pre-§2a relay (404 on /whoami), an unreachable relay, or a key label that
54
+ // is not a valid agent name (relay's own name regex — labels may contain spaces) all fall
55
+ // through to the anon default: never crash, never try to register an unregistrable name.
56
+ // Key-derived names are stable but NOT explicit — the lock's `explicit` field stays
57
+ // "AGENT_NAME was set in the env", exactly as §1c defines it.
58
+ const ANON_NAME = `anon-${hostname()}-${process.pid}`
59
+ const AGENT_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/
60
+ async function deriveKeyName() {
61
+ try {
62
+ // Bounded (5s): startup must never stall the MCP connect on a hanging relay — a slow
63
+ // whoami degrades to the anon name, exactly like a 404 from a pre-§2a relay.
64
+ const res = await fetch(`${API}/whoami`, { headers: authHeaders(), signal: AbortSignal.timeout(5000) })
65
+ if (!res.ok) return null
66
+ const data = await res.json().catch(() => ({}))
67
+ const kn = typeof data.key_name === 'string' ? data.key_name.trim() : ''
68
+ if (AGENT_NAME_RE.test(kn)) return kn
69
+ } catch {}
70
+ return null
71
+ }
72
+ const NAME = process.env.AGENT_NAME || (await deriveKeyName()) || ANON_NAME
73
+
74
+ // Same-session identity (routing-v2 §1c). `session` is the ownership signal for
75
+ // duplicate-in-session detection; `explicit` records whether the OPERATOR chose this
76
+ // name (AGENT_NAME set) — NOT whether the name is unsuffixed. The default anon-<host>-<pid>
77
+ // name takes the base lock unsuffixed yet is never explicit. `SWITCHBOARD_ALLOW_SAME_SESSION=1`
78
+ // disables the scan entirely (tests, exotic setups; CI has no session env so it no-ops anyway).
79
+ const SESSION = process.env.CLAUDE_CODE_SESSION_ID || null
80
+ const EXPLICIT = 'AGENT_NAME' in process.env && !!process.env.AGENT_NAME
81
+ const ALLOW_SAME_SESSION = process.env.SWITCHBOARD_ALLOW_SAME_SESSION === '1'
45
82
 
46
83
  const SB_DIR = join(homedir(), '.switchboard')
47
84
  const idCachePath = (name) => join(SB_DIR, `${name}.id`)
@@ -53,6 +90,32 @@ const pidAlive = (pid) => {
53
90
 
54
91
  const MAX_SUFFIX_HOPS = 8
55
92
 
93
+ // Lock-file body (routing-v2 §1c): JSON `{pid,session,name,explicit}`. Written on every
94
+ // lock acquisition so a same-session sibling can read our metadata and both sides compute
95
+ // the same winner from lock files alone (no signaling). Legacy ≤1.2.x clients wrote a bare
96
+ // pid string — `parseLock` still honors those (staleness by pid; session null; explicit
97
+ // false), so a mixed fleet during rollout stays safe.
98
+ function lockBody(name) {
99
+ return JSON.stringify({ pid: process.pid, session: SESSION, name, explicit: EXPLICIT })
100
+ }
101
+ function parseLock(raw) {
102
+ const s = (raw || '').trim()
103
+ if (!s) return null
104
+ try {
105
+ const o = JSON.parse(s)
106
+ if (o && typeof o === 'object' && Number.isFinite(Number(o.pid))) {
107
+ return { pid: Number(o.pid), session: o.session ?? null, name: o.name ?? null, explicit: o.explicit === true }
108
+ }
109
+ } catch {}
110
+ // Legacy bare-pid lock from a ≤1.2.x client.
111
+ const pid = Number(s)
112
+ if (Number.isFinite(pid)) return { pid, session: null, name: null, explicit: false }
113
+ return null
114
+ }
115
+ function readLock(name) {
116
+ try { return parseLock(readFileSync(lockPath(name), 'utf8')) } catch { return null }
117
+ }
118
+
56
119
  // Take the lock for one name, atomically. Creation is ALWAYS `wx` (exclusive) — a stale
57
120
  // (dead-pid) lock is unlinked and creation re-attempted, so when two crash successors
58
121
  // race the takeover, exactly one wx-create wins and the loser moves to the next suffix.
@@ -61,15 +124,15 @@ const MAX_SUFFIX_HOPS = 8
61
124
  function tryLock(name) {
62
125
  for (let attempt = 0; attempt < 2; attempt++) {
63
126
  try {
64
- writeFileSync(lockPath(name), String(process.pid), { flag: 'wx' })
127
+ writeFileSync(lockPath(name), lockBody(name), { flag: 'wx' })
65
128
  } catch {
66
- let holder = NaN
67
- try { holder = Number(readFileSync(lockPath(name), 'utf8').trim()) } catch {}
68
- if (pidAlive(holder)) return false
129
+ const holder = readLock(name)
130
+ if (holder && pidAlive(holder.pid)) return false
69
131
  try { unlinkSync(lockPath(name)) } catch {}
70
132
  continue // stale lock removed — re-attempt the exclusive create (one winner)
71
133
  }
72
- try { if (readFileSync(lockPath(name), 'utf8').trim() === String(process.pid)) return true } catch {}
134
+ const back = readLock(name)
135
+ if (back && back.pid === process.pid) return true
73
136
  return false
74
137
  }
75
138
  return false
@@ -90,12 +153,102 @@ function acquireIdentity() {
90
153
 
91
154
  function releaseIdentity(identity) {
92
155
  try {
93
- if (readFileSync(lockPath(identity.name), 'utf8').trim() === String(process.pid))
94
- unlinkSync(lockPath(identity.name))
156
+ const held = readLock(identity.name)
157
+ if (held && held.pid === process.pid) unlinkSync(lockPath(identity.name))
95
158
  } catch {}
96
159
  }
97
160
 
98
- const state = { id: null, subscribing: false, running: true, identity: acquireIdentity(), sseCtl: null }
161
+ // --- same-session duplicate detection (routing-v2 §1c) ----------------------
162
+ // The incident: a second switchboard client under the SAME Claude Code session mints a
163
+ // second identity and both serve, splitting inbound/outbound. Defense: after we hold our
164
+ // own lock, scan every *.lock for another LIVE lock with our session id; if one beats us
165
+ // under the deterministic metadata tiebreak, WE lose and go inert. Both sides read each
166
+ // other's lock files and compute the same winner — no signaling protocol.
167
+ //
168
+ // Tiebreak (on recorded lock metadata, symmetric): explicit:true beats explicit:false;
169
+ // still tied → lower pid wins. Raw pid is NOT the primary key — the incident's stale anon
170
+ // client is the OLDER (lower-pid) process and must lose to the newer explicit-named one.
171
+ // Returns true iff `other` beats `self`.
172
+ function otherWins(self, other) {
173
+ if (self.explicit !== other.explicit) return other.explicit === true
174
+ return other.pid < self.pid
175
+ }
176
+
177
+ // Scan for a live same-session lock that beats US. `self` is our own {pid,session,explicit}.
178
+ // Returns the winning peer's parsed lock (naming its identity) or null. Idle-cheap: a stat +
179
+ // small read per lock file, only the handful in ~/.switchboard. Never matches a null session
180
+ // (legacy/anon-no-session locks can't collide) so the escape hatch and CI both no-op.
181
+ function findSameSessionWinner(self) {
182
+ if (ALLOW_SAME_SESSION || !self.session) return null
183
+ let files
184
+ try { files = readdirSync(SB_DIR) } catch { return null }
185
+ for (const f of files) {
186
+ if (!f.endsWith('.lock')) continue
187
+ const other = readLock(f.slice(0, -'.lock'.length))
188
+ if (!other) continue
189
+ if (other.pid === self.pid) continue // our own lock
190
+ if (!other.session || other.session !== self.session) continue // different session (or session-less)
191
+ if (!pidAlive(other.pid)) continue // dead pid → stale, ignore (staleness stays pid-based)
192
+ if (otherWins(self, other)) return other
193
+ }
194
+ return null
195
+ }
196
+
197
+ const state = {
198
+ id: null, subscribing: false, running: true, identity: acquireIdentity(), sseCtl: null, inert: false,
199
+ // §2e attached shared mailboxes for register/reconcile. Seeded from AGENT_MAILBOXES; an
200
+ // announce `mailboxes` argument can set it ONLY when the env list is empty (env wins).
201
+ mailboxes: AGENT_MAILBOXES,
202
+ }
203
+
204
+ function selfLock() {
205
+ return { pid: process.pid, session: SESSION, explicit: EXPLICIT }
206
+ }
207
+
208
+ // One loud stderr line + the message every inert tool call returns. Names the surviving
209
+ // identity and the operator fix.
210
+ function inertMessage(winnerName) {
211
+ return `another switchboard client in this session already owns '${winnerName}' — remove the duplicate MCP config entry`
212
+ }
213
+
214
+ // Loser path: stop serving, drop everything, log once. Used both at startup (before we ever
215
+ // registered) and for active convergence (demote in place while running). Idempotent.
216
+ async function goInert(winnerName) {
217
+ if (state.inert) return
218
+ state.inert = true
219
+ state.running = false
220
+ state.inertMsg = inertMessage(winnerName)
221
+ try { state.sseCtl?.abort() } catch {}
222
+ releaseIdentity(state.identity)
223
+ if (state.id) { await relay('POST', '/logoff', { id: state.id }).catch(() => {}) ; state.id = null }
224
+ console.error(`@wastedtokens/agent-switchboard: ${state.inertMsg}`)
225
+ }
226
+
227
+ // Active convergence check (routing-v2 §1c): run before serving each tool call, at the top
228
+ // of each subscribeLoop reconnect, and on a 60s timer. If a live same-session lock now beats
229
+ // us, demote in place. Returns true iff we (are/became) inert.
230
+ async function convergeInert() {
231
+ if (state.inert) return true
232
+ const winner = findSameSessionWinner(selfLock())
233
+ if (winner) { await goInert(winner.name); return true }
234
+ return false
235
+ }
236
+
237
+ // Startup arbitration (routing-v2 §1c "lock first, then scan"): acquireIdentity() already
238
+ // took our lock above; now check for a live same-session lock that beats us. If found we
239
+ // release and go inert BEFORE registering or opening any stream. Nothing to log off (no id
240
+ // yet), so this stays synchronous. Two same-session clients spawning concurrently both run
241
+ // lock→scan→tiebreak and converge on one survivor.
242
+ {
243
+ const winner = ALLOW_SAME_SESSION ? null : findSameSessionWinner(selfLock())
244
+ if (winner) {
245
+ state.inert = true
246
+ state.running = false
247
+ state.inertMsg = inertMessage(winner.name)
248
+ releaseIdentity(state.identity)
249
+ console.error(`@wastedtokens/agent-switchboard: ${state.inertMsg}`)
250
+ }
251
+ }
99
252
 
100
253
  function authHeaders(extra = {}) {
101
254
  return { 'Content-Type': 'application/json', Authorization: `Bearer ${KEY}`, ...extra }
@@ -113,6 +266,12 @@ async function relay(method, path, body) {
113
266
  return { status: res.status, retryAfter: Number(res.headers.get('retry-after')) || 0, data }
114
267
  }
115
268
 
269
+ // Degradation probe (routing-v2 Rollout): a relay that predates a route 404s it with a
270
+ // non-JSON (HTML) or empty body — relay() surfaces that as `http 404` or `{}`. A JSON 404
271
+ // from the route itself ("no such mailbox: X", "no such task") carries its own error string
272
+ // and passes through untouched. Same pattern as the pre-#12 receipt fallback in selftest.
273
+ const preRoutingV2 = (r) => r.status === 404 && (!r.data.error || r.data.error === 'http 404')
274
+
116
275
  // CLI selftest (issue #12): a full round-trip through the CONFIGURED relay — register ->
117
276
  // subscribe (SSE) -> send -> receive the push -> ack -> receipt — so an operator can prove
118
277
  // the edge/proxy path end-to-end before wiring it into a session. Exits 0 (pass) / 1 (fail).
@@ -201,7 +360,22 @@ async function register() {
201
360
  const r = await relay('POST', '/register', {
202
361
  id: state.id || cached, name: ident.name, project: PROJECT,
203
362
  machine: MACHINE, capabilities: CAPS, presence: 'live', ephemeral: ident.ephemeral,
363
+ // §2e: attach shared mailboxes per-announce; the response's reconcile block reports
364
+ // queued counts for them. Old relays ignore unknown body fields — wire-compatible.
365
+ ...(state.mailboxes.length ? { mailboxes: state.mailboxes } : {}),
204
366
  })
367
+ // Cancellation boundary (codex round 1, high): a same-session winner can appear WHILE this
368
+ // /register is in flight, so goInert() may have run with state.id still null (no /logoff). If
369
+ // we've since gone inert, do NOT mutate active state or hop a new suffix — undo instead:
370
+ // best-effort /logoff any id the relay just handed us, drop our lock, and report failure so no
371
+ // caller subscribes or sends. Without this a demoted loser could resurrect a live relay
372
+ // identity from the resuming register response.
373
+ if (state.inert || !state.running) {
374
+ if (r.data.id) relay('POST', '/logoff', { id: r.data.id }).catch(() => {})
375
+ releaseIdentity(state.identity)
376
+ state.id = null
377
+ return { status: r.status, retryAfter: r.retryAfter, data: { error: 'inert' } }
378
+ }
205
379
  if (r.status === 409) {
206
380
  // name owned by another LIVE process (other machine, or a lock we couldn't see):
207
381
  // take the next suffix as an ephemeral identity and let the caller retry.
@@ -221,8 +395,10 @@ async function register() {
221
395
  // 30s-backoff path — a buggy or hostile relay can never spin this client hot.
222
396
  async function registerWithSuffixRetry() {
223
397
  let r = await register()
224
- for (let hop = 0; hop < MAX_SUFFIX_HOPS && r.status === 409; hop++) r = await register()
225
- if (r.status === 409)
398
+ // Stop hopping the moment we've gone inert (register() already undid its side effects and
399
+ // returned {error:'inert'}); never keep 409-suffixing on a demoted client.
400
+ for (let hop = 0; hop < MAX_SUFFIX_HOPS && r.status === 409 && !state.inert; hop++) r = await register()
401
+ if (r.status === 409 && !state.inert)
226
402
  console.error('@wastedtokens/agent-switchboard: relay refused every suffixed name (409) — giving up this attempt')
227
403
  return r
228
404
  }
@@ -265,12 +441,16 @@ async function pushChannel(server, m) {
265
441
  }
266
442
 
267
443
  async function subscribeLoop(server) {
268
- if (state.subscribing) return
444
+ if (state.subscribing || state.inert) return
269
445
  state.subscribing = true
270
446
  while (state.running) {
447
+ // Active convergence (routing-v2 §1c): a same-session client may have won since we last
448
+ // looked — demote in place before (re)attaching the stream. goInert aborts sseCtl + logs off.
449
+ if (await convergeInert()) break
271
450
  try {
272
451
  if (!state.id) {
273
452
  const r = await registerWithSuffixRetry()
453
+ if (state.inert) break // demoted during the in-flight register — never subscribe
274
454
  if (!state.id) {
275
455
  // Registration REJECTED (bad name, agent cap, rate limit, exhausted 409
276
456
  // cascade...): back off — never hit /subscribe without an id, never
@@ -324,7 +504,7 @@ const fail = (obj) => ({ isError: true, content: [{ type: 'text', text: JSON.str
324
504
 
325
505
  // --- MCP server + tools -----------------------------------------------------
326
506
  const server = new Server(
327
- { name: 'switchboard', version: '1.2.0' },
507
+ { name: 'switchboard', version: '1.3.0' },
328
508
  {
329
509
  capabilities: { experimental: { 'claude/channel': {} }, tools: {} },
330
510
  instructions:
@@ -334,13 +514,26 @@ const server = new Server(
334
514
  'and the SAME thread. Start with `announce` so peers can find you and so messages start ' +
335
515
  'flowing; use `who_is_online` to see peers; `send_message` to start a new conversation; ' +
336
516
  '`logoff` when done. A message with a non-empty control attribute is a lifecycle signal, ' +
337
- 'not conversation.',
517
+ 'not conversation. Shared work queues: `claim_next` pulls the oldest unclaimed message ' +
518
+ 'from a shared mailbox (claiming also moves its open task to working under you); ' +
519
+ '`task_update` reports task progress (input-required / completed / failed).',
338
520
  },
339
521
  )
340
522
 
341
523
  const TOOLS = [
342
- { name: 'announce', description: 'Register/refresh this agent on the switchboard, begin receiving pushed peer messages, and return the live directory. Call once at session start.',
343
- inputSchema: { type: 'object', properties: {} } },
524
+ { name: 'announce', description: 'Register/refresh this agent on the switchboard, begin receiving pushed peer messages, and return the live directory plus a reconcile block (queued counts for your mailbox, attached shared mailboxes, and your sticky threads — pull-for-truth on attach; older relays omit it).',
525
+ inputSchema: { type: 'object', properties: {
526
+ mailboxes: { type: 'array', items: { type: 'string' }, description: 'shared mailboxes to attach for reconcile counts (ignored when AGENT_MAILBOXES is set — env wins)' },
527
+ } } },
528
+ { name: 'claim_next', description: 'Claim the oldest unclaimed message from a shared mailbox (work queue). The claimed message is delivered to this session and acked under YOUR identity; any open task on its thread moves to working, claimed by you. Empty result = nothing queued.',
529
+ inputSchema: { type: 'object', properties: {
530
+ mailbox: { type: 'string', description: 'shared mailbox name (kind=mailbox) to claim from' },
531
+ }, required: ['mailbox'] } },
532
+ { name: 'task_update', description: 'Update a task\'s state: working | input-required (escalated, waiting on the requester) | completed | failed. Tasks are created automatically when a message lands in a shared mailbox; claiming moves them to working. Closed tasks stay closed.',
533
+ inputSchema: { type: 'object', properties: {
534
+ id: { type: 'string', description: 'task id (task_…)' },
535
+ state: { type: 'string', enum: ['working', 'input-required', 'completed', 'failed'] },
536
+ }, required: ['id', 'state'] } },
344
537
  { name: 'who_is_online', description: 'List your agents on the switchboard with presence (live/offline), project, machine, and queued counts.',
345
538
  inputSchema: { type: 'object', properties: {} } },
346
539
  { name: 'send_message', description: 'Send a message to another of your agents by name or id. Queued for delivery if the recipient is offline.',
@@ -355,7 +548,7 @@ const TOOLS = [
355
548
  to: { type: 'string' }, thread: { type: 'string' }, body: { type: 'string' },
356
549
  in_reply_to: { type: 'string', description: 'optional msg_id being answered' },
357
550
  }, required: ['to', 'thread', 'body'] } },
358
- { name: 'message_status', description: 'Delivery receipt for a message you sent: queued | leased | delivered | expired.',
551
+ { name: 'message_status', description: 'Delivery receipt for a message you sent: queued | leased | delivered | expired. Includes delivered_to — which identity is consuming the addressed mailbox (rename-safe; older relays may omit it).',
359
552
  inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'message id from send_message' } }, required: ['id'] } },
360
553
  { name: 'logoff', description: 'Go offline now and stop receiving pushes.',
361
554
  inputSchema: { type: 'object', properties: {} } },
@@ -365,25 +558,97 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }))
365
558
 
366
559
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
367
560
  const a = req.params.arguments || {}
561
+ // logoff must stay functional even when inert (a demoted client still cleans up on exit).
562
+ // Every other tool re-runs the same-session scan first (active convergence, routing-v2 §1c):
563
+ // an already-running client demotes the instant a live same-session peer beats it, and an
564
+ // inert client refuses every call with the one loud message.
565
+ if (req.params.name !== 'logoff') {
566
+ await convergeInert()
567
+ if (state.inert) return fail({ error: state.inertMsg })
568
+ }
368
569
  switch (req.params.name) {
369
570
  case 'announce': {
571
+ // §2e mailbox attachment — a pure function of (env, argument) on EVERY announce:
572
+ // AGENT_MAILBOXES wins when set (config-managed setups can't be re-pointed by a tool
573
+ // call); otherwise the argument, and an absent argument means NO attachment. Nothing
574
+ // persists relay-side; reconnect re-registers just re-send this announce's attachment.
575
+ state.mailboxes = AGENT_MAILBOXES.length
576
+ ? AGENT_MAILBOXES
577
+ : Array.isArray(a.mailboxes)
578
+ ? a.mailboxes.filter((m) => typeof m === 'string' && m.trim()).map((m) => m.trim()).slice(0, 16)
579
+ : []
370
580
  const r = await registerWithSuffixRetry()
581
+ // A same-session winner may have appeared during that in-flight register (codex round 1):
582
+ // register() already undid its state, so recheck before opening the push stream.
583
+ if (state.inert) return fail({ error: state.inertMsg })
371
584
  if (!state.id) return fail({ error: `registration failed: ${r.data.error || `http ${r.status}`}` })
372
585
  subscribeLoop(server) // fire-and-forget background push loop
373
- return ok((await relay('GET', '/directory')).data)
586
+ const dir = (await relay('GET', '/directory')).data
587
+ // §2e pull-for-truth on attach: surface the register response's reconcile block (queued
588
+ // counts for own mailbox / attached shared mailboxes / sticky threads). A pre-1.6 relay
589
+ // returns no reconcile — omit it rather than fabricate one.
590
+ return ok(r.data.reconcile !== undefined ? { ...dir, reconcile: r.data.reconcile } : dir)
374
591
  }
375
592
  case 'who_is_online':
376
593
  return ok((await relay('GET', '/directory')).data)
377
594
  case 'message_status':
378
595
  return ok((await relay('GET', `/messages/${encodeURIComponent(a.id)}`)).data)
596
+ case 'claim_next': {
597
+ // §2e: per-message claim from a shared work queue. Needs a registered consumer identity
598
+ // (the relay refuses mailbox/unknown consumers); lazy-register like send_message does.
599
+ if (!state.id) await registerWithSuffixRetry()
600
+ if (state.inert) return fail({ error: state.inertMsg }) // demoted during the lazy register
601
+ if (!state.id) return fail({ error: 'registration failed — cannot claim without a consumer identity' })
602
+ // Pin the consumer id: the ack and any hand-back must use the id the claim was leased
603
+ // under, never mutable state.id (subscribeLoop can re-register concurrently).
604
+ const consumerId = state.id
605
+ // The relay may lease rows server-side even when WE never see the response (network
606
+ // drop, body read failure) — never let a throw bypass the cancellation check below
607
+ // (codex k2 round 2, medium).
608
+ let r = null
609
+ try { r = await relay('POST', '/claim', { mailbox: a.mailbox, agent_id: consumerId, max: 1 }) } catch {}
610
+ // Cancellation boundary (codex k2 round 1, high): same-session demotion can land WHILE
611
+ // /claim is in flight — goInert logs off and nulls state.id, but the resumed tool would
612
+ // still inject the claimed work into a session that must be inert, and its lease would
613
+ // dangle until expiry. Re-check before touching any message: on demotion, hand the rows
614
+ // back UNCONDITIONALLY (a failed/errored response may still have leased server-side;
615
+ // /logoff is idempotent and requeues consumerId's leased rows — goInert's own logoff may
616
+ // have run BEFORE the relay processed the claim, so this second, awaited-bounded one is
617
+ // what releases the late lease) and refuse with the inert error. Nothing pushed or acked.
618
+ if (state.inert || !state.running || state.id !== consumerId) {
619
+ await Promise.race([relay('POST', '/logoff', { id: consumerId }), sleep(2000)]).catch(() => {})
620
+ return fail({ error: state.inertMsg || 'consumer identity changed during the claim — retry claim_next' })
621
+ }
622
+ // Live client, lost response: any server-side lease returns to the pool via lease expiry
623
+ // (bounded, the existing at-least-once design) — logoff here would kill a healthy stream.
624
+ if (!r) return fail({ error: 'claim request failed (network) — retry claim_next' })
625
+ if (preRoutingV2(r)) return fail({ error: 'this relay does not support shared mailboxes yet (pre-1.6 server)' })
626
+ if (r.status !== 200) return fail(r.data) // 404 no such mailbox / 409 not a mailbox|is a mailbox — wire error passthrough
627
+ // Deliver exactly like the inbox drain: push into the session, then ack ONLY what was
628
+ // pushed (a failed push stays leased and returns to the pool on lease expiry). The ack
629
+ // runs under the pinned consumer id — the §2b ownership predicate finalizes claimed rows
630
+ // under the consumer, so the receipt's delivered_to reports this identity, not the mailbox.
631
+ const msgs = r.data.messages || []
632
+ const acks = []
633
+ for (const m of msgs) { const acked = await pushChannel(server, m); if (acked) acks.push(acked) }
634
+ if (acks.length) await relay('POST', '/ack', { agent_id: consumerId, ids: acks }).catch(() => {})
635
+ return ok({ claimed: msgs.length, messages: msgs })
636
+ }
637
+ case 'task_update': {
638
+ const r = await relay('POST', `/tasks/${encodeURIComponent(a.id)}`, { state: a.state })
639
+ if (preRoutingV2(r)) return fail({ error: 'this relay does not support tasks yet (pre-1.6 server)' })
640
+ return r.status === 200 ? ok(r.data) : fail(r.data) // 400 invalid state / 404 no such task / 409 task closed
641
+ }
379
642
  case 'send_message':
380
643
  if (!state.id) await registerWithSuffixRetry()
644
+ if (state.inert) return fail({ error: state.inertMsg }) // demoted during the lazy register
381
645
  return ok((await relay('POST', '/send', {
382
646
  from: state.identity.name, to: a.to, body: a.body, subject: a.subject || '',
383
647
  thread: a.thread || null,
384
648
  })).data)
385
649
  case 'reply':
386
650
  if (!state.id) await registerWithSuffixRetry()
651
+ if (state.inert) return fail({ error: state.inertMsg }) // demoted during the lazy register
387
652
  return ok((await relay('POST', '/send', {
388
653
  from: state.identity.name, to: a.to, body: a.body, thread: a.thread, in_reply_to: a.in_reply_to || null,
389
654
  })).data)
@@ -419,8 +684,23 @@ for (const sig of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(sig, shutdown)
419
684
 
420
685
  await server.connect(new StdioServerTransport())
421
686
 
687
+ // Active-convergence timer (routing-v2 §1c, axis iii): a purely-idle client whose SSE stream
688
+ // is healthy never re-checks via the loop or a tool call, so a 60s timer catches it. Idle-cheap
689
+ // (stat + small read of a handful of lock files). `unref`'d so it never keeps the process alive;
690
+ // self-clears once inert (demotion is one-way). Skipped entirely when the scan is disabled.
691
+ if (!ALLOW_SAME_SESSION && SESSION) {
692
+ // 60s default; SWITCHBOARD_CONVERGE_MS lets integration tests tick fast (the incident-shape
693
+ // test can't wait a real minute for an idle incumbent to demote). Clamped to ≥250ms.
694
+ const everyMs = Math.max(250, Number(process.env.SWITCHBOARD_CONVERGE_MS) || 60_000)
695
+ const timer = setInterval(() => {
696
+ if (state.inert) { clearInterval(timer); return }
697
+ convergeInert().catch(() => {})
698
+ }, everyMs)
699
+ timer.unref?.()
700
+ }
701
+
422
702
  // Auto-announce (issue #12): opt-in instant presence — register + open the push stream on
423
703
  // connect, so a session is reachable without the operator remembering to call `announce`.
424
- if (process.env.SWITCHBOARD_AUTO_ANNOUNCE === '1') {
704
+ if (process.env.SWITCHBOARD_AUTO_ANNOUNCE === '1' && !state.inert) {
425
705
  registerWithSuffixRetry().then(() => { if (state.id) subscribeLoop(server) }).catch(() => {})
426
706
  }