@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 +101 -16
- package/package.json +2 -2
- package/switchboard-channel.mjs +301 -21
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)
|
|
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=
|
|
16
|
-
-- npx -y @wastedtokens/agent-switchboard@
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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`)
|
|
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
|
-
|
|
44
|
-
|
|
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@
|
|
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
|
|
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.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Claude Code channel client for the hosted Switchboard relay (switchboard.wastedtokens.io)
|
|
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",
|
package/switchboard-channel.mjs
CHANGED
|
@@ -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),
|
|
127
|
+
writeFileSync(lockPath(name), lockBody(name), { flag: 'wx' })
|
|
65
128
|
} catch {
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
|
|
225
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
}
|