@wastedtokens/agent-switchboard 1.2.1 → 1.4.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,11 +8,23 @@ 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_... \
27
+ -e SWITCHBOARD_API_KEY="$SWITCHBOARD_API_KEY" \
16
28
  -- npx -y @wastedtokens/agent-switchboard@latest
17
29
  ```
18
30
 
@@ -22,34 +34,115 @@ 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), and
26
- **instant presence** so the session is reachable the moment it connects (see below):
27
-
28
- ```bash
29
- claude mcp add -s project switchboard \
30
- -e SWITCHBOARD_API_KEY=wtsb_... \
31
- -e AGENT_NAME=my-project-agent -e AGENT_PROJECT="$PWD" \
32
- -e SWITCHBOARD_AUTO_ANNOUNCE=1 \
33
- -- npx -y @wastedtokens/agent-switchboard@latest
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
+ }
34
61
  ```
35
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
+
36
72
  ## Use
37
73
 
38
74
  In-session: `announce` once, `who_is_online`, `send_message(to, body)`, and answer pushed
39
75
  `<channel source="switchboard" from="X" thread="Y">` messages with
40
76
  `reply(to="X", thread="Y", body=...)`. `message_status(id)` returns the delivery receipt
41
- (`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. `thread_history(thread)` reads a thread's prior messages when you join
79
+ mid-conversation (see *Taking over a thread* below). `logoff` when done.
80
+
81
+ ### Shared mailboxes (work queues)
82
+
83
+ A **shared mailbox** (created on the dashboard or via `POST /api/v1/mailboxes`) is a durable
84
+ queue any of your sessions can serve — the "support@" pattern. Sending to it works like any
85
+ send; consuming is per-message:
86
+
87
+ - `announce(mailboxes: ["support"])` attaches mailboxes for the announce **reconcile block** —
88
+ real queued counts for your own mailbox, each attached shared mailbox, and threads you're
89
+ the sticky consumer for (pull-for-truth after a gap). Set `AGENT_MAILBOXES=support,triage`
90
+ instead to pin the attachment in config — **the env wins over the tool argument**.
91
+ - `claim_next(mailbox)` claims the oldest unclaimed message: it's pushed into your session,
92
+ acked under **your** identity (the receipt's `delivered_to` names you, not the mailbox),
93
+ and any open task on its thread moves to `working`, claimed by you. An empty result means
94
+ the queue is drained.
95
+ - `task_update(id, state)` reports work-truth: `working` | `input-required` (escalated,
96
+ waiting on the requester) | `completed` | `failed`. Tasks auto-create as `submitted` when a
97
+ message lands in a shared mailbox; closed tasks stay closed.
98
+
99
+ Against an older relay (pre-1.6) these tools degrade to a clear error ("does not support
100
+ shared mailboxes/tasks yet") instead of failing cryptically; the reconcile block is simply
101
+ omitted.
102
+
103
+ ### Taking over a thread (`thread_history`)
104
+
105
+ When you `claim_next` a message, you land mid-conversation: the relay hands you exactly one
106
+ message row, but that thread may have a long history — the previous consumer went offline,
107
+ their stickiness lapsed, and the requester's next message fell back to the pool for you to
108
+ pick up. `thread_history(thread)` reads the prior messages so you can catch up before you
109
+ reply. It's a **read-only observation** — it never claims or consumes, and it's tenant-scoped
110
+ (you can read any of *your* threads).
111
+
112
+ - Every claimed (and pushed) message carries its `thread`; a claimed message also carries
113
+ `task_id`/`task_state` so you can drive the task with `task_update`.
114
+ - `thread_history(thread)` returns the thread's messages oldest→newest, plus `has_more`,
115
+ `next_before`, and `control_omitted` (relay lifecycle frames are filtered out of the
116
+ conversation by default and only counted). The `task` and `affinity` blocks tell you the
117
+ thread's open work and its sticky consumer.
118
+ - **Paging:** the newest page comes back first (default 100, max 200 messages). When
119
+ `has_more` is `true`, call again with `before: <next_before>` to walk older messages until
120
+ `has_more` is `false`.
121
+
122
+ A typical takeover:
123
+
124
+ ```
125
+ claim_next(mailbox: "support") # → { messages: [{ thread, task_id, task_state, … }] }
126
+ thread_history(thread: "<that thread>") # → catch up on the conversation so far
127
+ # … page older with before: next_before while has_more …
128
+ reply(to: "<requester>", thread: "<that thread>", body: "…") # answer in-context
129
+ task_update(id: "<task_id>", state: "completed") # close the loop
130
+ ```
131
+
132
+ Against a relay older than **1.8.0** (no thread-read route) `thread_history` returns a clear
133
+ "relay too old — upgrade to ≥1.8.0" error rather than failing cryptically.
42
134
 
43
135
  ### Instant presence + clean shutdown
44
136
 
45
137
  By default the channel is idle until the agent calls `announce`. Set
46
138
  `SWITCHBOARD_AUTO_ANNOUNCE=1` and it instead registers and opens its push stream the moment it
47
- connects — no manual `announce` needed; the session is reachable from the start. Add it as one
48
- more `-e` flag (as in the named-agent install above):
139
+ connects — no manual `announce` needed; the session is reachable from the start. It's already in
140
+ the committed-`.mcp.json` example above; for a user-scope install add it as one more `-e` flag
141
+ (key by env-var indirection, never inline):
49
142
 
50
143
  ```bash
51
- claude mcp add -s project switchboard \
52
- -e SWITCHBOARD_API_KEY=wtsb_... -e AGENT_NAME=my-project-agent \
144
+ claude mcp add -s user switchboard \
145
+ -e SWITCHBOARD_API_KEY="$SWITCHBOARD_API_KEY" -e AGENT_NAME=my-agent \
53
146
  -e SWITCHBOARD_AUTO_ANNOUNCE=1 \
54
147
  -- npx -y @wastedtokens/agent-switchboard@latest
55
148
  ```
@@ -69,6 +162,14 @@ the same name doesn't fight for the mailbox — it takes a suffixed **ephemeral*
69
162
  (`my-agent-2`, `-3`, …) that prunes automatically after 24h. Cross-machine name collisions
70
163
  resolve the same way (the relay returns 409, the client re-suffixes and retries).
71
164
 
165
+ **Same-session self-defense.** Two switchboard clients under the *same Claude Code session*
166
+ (e.g. a mid-session config change that adds a second MCP entry) would otherwise split your
167
+ inbound and outbound traffic across two identities. The client detects this from its lock
168
+ files and keeps exactly one live: the newer/explicitly-named client wins, the other goes
169
+ **inert** (serves no tools, opens no stream) and logs one line naming the survivor and the fix
170
+ — remove the duplicate MCP config entry. An already-running client demotes itself in place if a
171
+ same-session peer supersedes it. Set `SWITCHBOARD_ALLOW_SAME_SESSION=1` to disable the check.
172
+
72
173
  ### Prove the relay round-trip
73
174
 
74
175
  ```bash
@@ -86,9 +187,11 @@ path end-to-end before wiring the channel into a session. (Pass the key inline
86
187
  |---|---|
87
188
  | `SWITCHBOARD_API_KEY` | **required** — your per-user key (`wtsb_…`) |
88
189
  | `SWITCHBOARD_URL` | relay base URL (default `https://switchboard.wastedtokens.io`) |
89
- | `AGENT_NAME` | stable directory identity; omit for an ephemeral anonymous name. A concurrent second process with the same name auto-suffixes (`-2`, `-3`, …) |
190
+ | `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`, …) |
191
+ | `AGENT_MAILBOXES` | comma list of shared mailboxes to attach at announce (reconcile counts); **wins over** the `announce` tool's `mailboxes` argument |
90
192
  | `AGENT_PROJECT` / `AGENT_MACHINE` / `AGENT_CAPABILITIES` | directory metadata |
91
193
  | `SWITCHBOARD_AUTO_ANNOUNCE` | `1` ⇒ register + open the push stream on connect (instant presence, no manual `announce`) |
194
+ | `SWITCHBOARD_ALLOW_SAME_SESSION` | `1` ⇒ disable same-session duplicate detection (tests / exotic setups) |
92
195
 
93
196
  Delivery is at-least-once (the relay redelivers on unacked streams); the client dedupes by
94
197
  message id, so your session sees each message once.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wastedtokens/agent-switchboard",
3
- "version": "1.2.1",
3
+ "version": "1.4.0",
4
4
  "type": "module",
5
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",
@@ -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.4.0' },
328
508
  {
329
509
  capabilities: { experimental: { 'claude/channel': {} }, tools: {} },
330
510
  instructions:
@@ -334,13 +514,34 @@ 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). When a claim ' +
520
+ 'or a wake lands you in an unfamiliar thread, `thread_history` reads the prior messages so ' +
521
+ 'you can catch up before replying.',
338
522
  },
339
523
  )
340
524
 
341
525
  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: {} } },
526
+ { 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).',
527
+ inputSchema: { type: 'object', properties: {
528
+ mailboxes: { type: 'array', items: { type: 'string' }, description: 'shared mailboxes to attach for reconcile counts (ignored when AGENT_MAILBOXES is set — env wins)' },
529
+ } } },
530
+ { 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. Each claimed message carries its thread plus task_id/task_state (null when the thread has no open task) — drive the task with task_update. Landed mid-conversation? Call thread_history with that thread to read what came before. Empty result = nothing queued.',
531
+ inputSchema: { type: 'object', properties: {
532
+ mailbox: { type: 'string', description: 'shared mailbox name (kind=mailbox) to claim from' },
533
+ }, required: ['mailbox'] } },
534
+ { name: 'thread_history', description: 'Read the prior messages of a thread for takeover context — a tenant-scoped, read-only observation that never claims or consumes. Pull it after claim_next lands you in an unfamiliar thread, or after a wake, to reconstruct the conversation before you joined. Returns messages oldest→newest plus has_more, next_before, and control_omitted; when has_more is true, call again with before: next_before to page older messages. The task and affinity blocks describe the thread\'s open work and its sticky consumer.',
535
+ inputSchema: { type: 'object', properties: {
536
+ thread: { type: 'string', description: 'thread id to read (the thread of a claimed/pushed message)' },
537
+ limit: { type: 'number', description: 'max messages this page (default 100, max 200)' },
538
+ before: { type: 'string', description: 'pagination cursor — pass the previous response\'s next_before to page older' },
539
+ }, required: ['thread'] } },
540
+ { 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.',
541
+ inputSchema: { type: 'object', properties: {
542
+ id: { type: 'string', description: 'task id (task_…)' },
543
+ state: { type: 'string', enum: ['working', 'input-required', 'completed', 'failed'] },
544
+ }, required: ['id', 'state'] } },
344
545
  { name: 'who_is_online', description: 'List your agents on the switchboard with presence (live/offline), project, machine, and queued counts.',
345
546
  inputSchema: { type: 'object', properties: {} } },
346
547
  { 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 +556,7 @@ const TOOLS = [
355
556
  to: { type: 'string' }, thread: { type: 'string' }, body: { type: 'string' },
356
557
  in_reply_to: { type: 'string', description: 'optional msg_id being answered' },
357
558
  }, required: ['to', 'thread', 'body'] } },
358
- { name: 'message_status', description: 'Delivery receipt for a message you sent: queued | leased | delivered | expired.',
559
+ { 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
560
  inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'message id from send_message' } }, required: ['id'] } },
360
561
  { name: 'logoff', description: 'Go offline now and stop receiving pushes.',
361
562
  inputSchema: { type: 'object', properties: {} } },
@@ -363,27 +564,163 @@ const TOOLS = [
363
564
 
364
565
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }))
365
566
 
567
+ // --- argument validation (issue #36 nit 2) ----------------------------------
568
+ // Reject a call with a missing/mistyped required arg or an unknown arg BEFORE any relay
569
+ // hit — a bare `message_status` used to send GET /messages/undefined (encodeURIComponent of
570
+ // undefined is the literal "undefined"), silently querying a nonexistent id. The check is
571
+ // driven off each tool's own inputSchema so every handler is covered uniformly. Required
572
+ // strings must be present and non-empty (they land in URL paths); optional strings may be
573
+ // empty; numbers accept a numeric value or numeric string (the relay parses either).
574
+ function typeError(k, v, spec, requireNonEmpty) {
575
+ const t = spec.type
576
+ if (t === 'string') {
577
+ if (typeof v !== 'string') return `parameter '${k}' must be a string`
578
+ if (requireNonEmpty && v.trim() === '') return `parameter '${k}' must not be empty`
579
+ } else if (t === 'number' || t === 'integer') {
580
+ const n = typeof v === 'number' ? v : typeof v === 'string' && v.trim() !== '' ? Number(v) : NaN
581
+ if (!Number.isFinite(n)) return `parameter '${k}' must be a number`
582
+ } else if (t === 'array') {
583
+ if (!Array.isArray(v)) return `parameter '${k}' must be an array`
584
+ }
585
+ return null
586
+ }
587
+ function validateArgs(name, args) {
588
+ const tool = TOOLS.find((t) => t.name === name)
589
+ if (!tool) return null // unknown tool name — let the switch default throw its own error
590
+ const props = tool.inputSchema?.properties || {}
591
+ const required = tool.inputSchema?.required || []
592
+ for (const k of Object.keys(args)) {
593
+ if (!(k in props))
594
+ return `unknown parameter '${k}' for ${name} (allowed: ${Object.keys(props).join(', ') || 'none'})`
595
+ }
596
+ for (const k of required) {
597
+ const v = args[k]
598
+ if (v === undefined || v === null) return `missing required parameter '${k}' for ${name}`
599
+ const err = typeError(k, v, props[k] || {}, true)
600
+ if (err) return err
601
+ }
602
+ for (const k of Object.keys(props)) {
603
+ if (required.includes(k)) continue
604
+ const v = args[k]
605
+ if (v === undefined || v === null) continue // optional + absent is fine
606
+ const err = typeError(k, v, props[k] || {}, false)
607
+ if (err) return err
608
+ }
609
+ return null
610
+ }
611
+
366
612
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
367
613
  const a = req.params.arguments || {}
614
+ // Param validation runs first so a malformed call fails clearly (and cheaply) before we
615
+ // touch identity/convergence or the relay — no GET /messages/undefined (issue #36 nit 2).
616
+ const paramErr = validateArgs(req.params.name, a)
617
+ if (paramErr) return fail({ error: paramErr })
618
+ // logoff must stay functional even when inert (a demoted client still cleans up on exit).
619
+ // Every other tool re-runs the same-session scan first (active convergence, routing-v2 §1c):
620
+ // an already-running client demotes the instant a live same-session peer beats it, and an
621
+ // inert client refuses every call with the one loud message.
622
+ if (req.params.name !== 'logoff') {
623
+ await convergeInert()
624
+ if (state.inert) return fail({ error: state.inertMsg })
625
+ }
368
626
  switch (req.params.name) {
369
627
  case 'announce': {
628
+ // §2e mailbox attachment — a pure function of (env, argument) on EVERY announce:
629
+ // AGENT_MAILBOXES wins when set (config-managed setups can't be re-pointed by a tool
630
+ // call); otherwise the argument, and an absent argument means NO attachment. Nothing
631
+ // persists relay-side; reconnect re-registers just re-send this announce's attachment.
632
+ state.mailboxes = AGENT_MAILBOXES.length
633
+ ? AGENT_MAILBOXES
634
+ : Array.isArray(a.mailboxes)
635
+ ? a.mailboxes.filter((m) => typeof m === 'string' && m.trim()).map((m) => m.trim()).slice(0, 16)
636
+ : []
370
637
  const r = await registerWithSuffixRetry()
638
+ // A same-session winner may have appeared during that in-flight register (codex round 1):
639
+ // register() already undid its state, so recheck before opening the push stream.
640
+ if (state.inert) return fail({ error: state.inertMsg })
371
641
  if (!state.id) return fail({ error: `registration failed: ${r.data.error || `http ${r.status}`}` })
372
642
  subscribeLoop(server) // fire-and-forget background push loop
373
- return ok((await relay('GET', '/directory')).data)
643
+ const dir = (await relay('GET', '/directory')).data
644
+ // §2e pull-for-truth on attach: surface the register response's reconcile block (queued
645
+ // counts for own mailbox / attached shared mailboxes / sticky threads). A pre-1.6 relay
646
+ // returns no reconcile — omit it rather than fabricate one.
647
+ return ok(r.data.reconcile !== undefined ? { ...dir, reconcile: r.data.reconcile } : dir)
374
648
  }
375
649
  case 'who_is_online':
376
650
  return ok((await relay('GET', '/directory')).data)
377
651
  case 'message_status':
378
652
  return ok((await relay('GET', `/messages/${encodeURIComponent(a.id)}`)).data)
653
+ case 'thread_history': {
654
+ // Takeover context (spec §3): read-only passthrough of GET /api/v1/threads/{thread}.
655
+ // The pagination surface (has_more / next_before / control_omitted) and the task/affinity
656
+ // blocks are returned UNCHANGED so the consumer can page and reason about them directly.
657
+ const q = new URLSearchParams()
658
+ if (a.limit !== undefined && a.limit !== null) q.set('limit', String(a.limit))
659
+ if (a.before) q.set('before', String(a.before))
660
+ const qs = q.toString()
661
+ const r = await relay('GET', `/threads/${encodeURIComponent(a.thread)}${qs ? `?${qs}` : ''}`)
662
+ // Graceful degrade on a pre-1.8 relay: the route 404s with a non-JSON body (preRoutingV2),
663
+ // distinct from the route's own JSON 404 "no such thread" — same pattern as the receipt
664
+ // fallback. Wire errors (404 no such thread / 400 bad cursor) pass straight through.
665
+ if (preRoutingV2(r)) return fail({ error: 'relay too old for thread history — upgrade the relay to ≥1.8.0' })
666
+ return r.status === 200 ? ok(r.data) : fail(r.data)
667
+ }
668
+ case 'claim_next': {
669
+ // §2e: per-message claim from a shared work queue. Needs a registered consumer identity
670
+ // (the relay refuses mailbox/unknown consumers); lazy-register like send_message does.
671
+ if (!state.id) await registerWithSuffixRetry()
672
+ if (state.inert) return fail({ error: state.inertMsg }) // demoted during the lazy register
673
+ if (!state.id) return fail({ error: 'registration failed — cannot claim without a consumer identity' })
674
+ // Pin the consumer id: the ack and any hand-back must use the id the claim was leased
675
+ // under, never mutable state.id (subscribeLoop can re-register concurrently).
676
+ const consumerId = state.id
677
+ // The relay may lease rows server-side even when WE never see the response (network
678
+ // drop, body read failure) — never let a throw bypass the cancellation check below
679
+ // (codex k2 round 2, medium).
680
+ let r = null
681
+ try { r = await relay('POST', '/claim', { mailbox: a.mailbox, agent_id: consumerId, max: 1 }) } catch {}
682
+ // Cancellation boundary (codex k2 round 1, high): same-session demotion can land WHILE
683
+ // /claim is in flight — goInert logs off and nulls state.id, but the resumed tool would
684
+ // still inject the claimed work into a session that must be inert, and its lease would
685
+ // dangle until expiry. Re-check before touching any message: on demotion, hand the rows
686
+ // back UNCONDITIONALLY (a failed/errored response may still have leased server-side;
687
+ // /logoff is idempotent and requeues consumerId's leased rows — goInert's own logoff may
688
+ // have run BEFORE the relay processed the claim, so this second, awaited-bounded one is
689
+ // what releases the late lease) and refuse with the inert error. Nothing pushed or acked.
690
+ if (state.inert || !state.running || state.id !== consumerId) {
691
+ await Promise.race([relay('POST', '/logoff', { id: consumerId }), sleep(2000)]).catch(() => {})
692
+ return fail({ error: state.inertMsg || 'consumer identity changed during the claim — retry claim_next' })
693
+ }
694
+ // Live client, lost response: any server-side lease returns to the pool via lease expiry
695
+ // (bounded, the existing at-least-once design) — logoff here would kill a healthy stream.
696
+ if (!r) return fail({ error: 'claim request failed (network) — retry claim_next' })
697
+ if (preRoutingV2(r)) return fail({ error: 'this relay does not support shared mailboxes yet (pre-1.6 server)' })
698
+ if (r.status !== 200) return fail(r.data) // 404 no such mailbox / 409 not a mailbox|is a mailbox — wire error passthrough
699
+ // Deliver exactly like the inbox drain: push into the session, then ack ONLY what was
700
+ // pushed (a failed push stays leased and returns to the pool on lease expiry). The ack
701
+ // runs under the pinned consumer id — the §2b ownership predicate finalizes claimed rows
702
+ // under the consumer, so the receipt's delivered_to reports this identity, not the mailbox.
703
+ const msgs = r.data.messages || []
704
+ const acks = []
705
+ for (const m of msgs) { const acked = await pushChannel(server, m); if (acked) acks.push(acked) }
706
+ if (acks.length) await relay('POST', '/ack', { agent_id: consumerId, ids: acks }).catch(() => {})
707
+ return ok({ claimed: msgs.length, messages: msgs })
708
+ }
709
+ case 'task_update': {
710
+ const r = await relay('POST', `/tasks/${encodeURIComponent(a.id)}`, { state: a.state })
711
+ if (preRoutingV2(r)) return fail({ error: 'this relay does not support tasks yet (pre-1.6 server)' })
712
+ return r.status === 200 ? ok(r.data) : fail(r.data) // 400 invalid state / 404 no such task / 409 task closed
713
+ }
379
714
  case 'send_message':
380
715
  if (!state.id) await registerWithSuffixRetry()
716
+ if (state.inert) return fail({ error: state.inertMsg }) // demoted during the lazy register
381
717
  return ok((await relay('POST', '/send', {
382
718
  from: state.identity.name, to: a.to, body: a.body, subject: a.subject || '',
383
719
  thread: a.thread || null,
384
720
  })).data)
385
721
  case 'reply':
386
722
  if (!state.id) await registerWithSuffixRetry()
723
+ if (state.inert) return fail({ error: state.inertMsg }) // demoted during the lazy register
387
724
  return ok((await relay('POST', '/send', {
388
725
  from: state.identity.name, to: a.to, body: a.body, thread: a.thread, in_reply_to: a.in_reply_to || null,
389
726
  })).data)
@@ -419,8 +756,23 @@ for (const sig of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(sig, shutdown)
419
756
 
420
757
  await server.connect(new StdioServerTransport())
421
758
 
759
+ // Active-convergence timer (routing-v2 §1c, axis iii): a purely-idle client whose SSE stream
760
+ // is healthy never re-checks via the loop or a tool call, so a 60s timer catches it. Idle-cheap
761
+ // (stat + small read of a handful of lock files). `unref`'d so it never keeps the process alive;
762
+ // self-clears once inert (demotion is one-way). Skipped entirely when the scan is disabled.
763
+ if (!ALLOW_SAME_SESSION && SESSION) {
764
+ // 60s default; SWITCHBOARD_CONVERGE_MS lets integration tests tick fast (the incident-shape
765
+ // test can't wait a real minute for an idle incumbent to demote). Clamped to ≥250ms.
766
+ const everyMs = Math.max(250, Number(process.env.SWITCHBOARD_CONVERGE_MS) || 60_000)
767
+ const timer = setInterval(() => {
768
+ if (state.inert) { clearInterval(timer); return }
769
+ convergeInert().catch(() => {})
770
+ }, everyMs)
771
+ timer.unref?.()
772
+ }
773
+
422
774
  // Auto-announce (issue #12): opt-in instant presence — register + open the push stream on
423
775
  // connect, so a session is reachable without the operator remembering to call `announce`.
424
- if (process.env.SWITCHBOARD_AUTO_ANNOUNCE === '1') {
776
+ if (process.env.SWITCHBOARD_AUTO_ANNOUNCE === '1' && !state.inert) {
425
777
  registerWithSuffixRetry().then(() => { if (state.id) subscribeLoop(server) }).catch(() => {})
426
778
  }