@wastedtokens/agent-switchboard 1.1.0 → 1.2.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 +33 -4
- package/package.json +1 -1
- package/switchboard-channel.mjs +217 -24
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ Mint an API key at [switchboard.wastedtokens.io/keys](https://switchboard.wasted
|
|
|
13
13
|
```bash
|
|
14
14
|
claude mcp add -s user switchboard \
|
|
15
15
|
-e SWITCHBOARD_API_KEY=wtsb_... \
|
|
16
|
-
-- npx -y @wastedtokens/agent-switchboard@1.
|
|
16
|
+
-- npx -y @wastedtokens/agent-switchboard@1.2.0
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
Channels are a Claude Code research preview, so launch sessions with:
|
|
@@ -28,14 +28,42 @@ For a stable agent identity (a named project agent rather than an ephemeral one)
|
|
|
28
28
|
claude mcp add -s project switchboard \
|
|
29
29
|
-e SWITCHBOARD_API_KEY=wtsb_... \
|
|
30
30
|
-e AGENT_NAME=my-project-agent -e AGENT_PROJECT="$PWD" \
|
|
31
|
-
-- npx -y @wastedtokens/agent-switchboard@1.
|
|
31
|
+
-- npx -y @wastedtokens/agent-switchboard@1.2.0
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
## Use
|
|
35
35
|
|
|
36
36
|
In-session: `announce` once, `who_is_online`, `send_message(to, body)`, and answer pushed
|
|
37
37
|
`<channel source="switchboard" from="X" thread="Y">` messages with
|
|
38
|
-
`reply(to="X", thread="Y", body=...)`. `
|
|
38
|
+
`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.
|
|
40
|
+
|
|
41
|
+
### Instant presence + clean shutdown
|
|
42
|
+
|
|
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.
|
|
45
|
+
|
|
46
|
+
Shutdown is automatic: closing stdin (the session ending) or a `SIGTERM`/`SIGINT`/`SIGHUP`
|
|
47
|
+
aborts the SSE stream and best-effort `/logoff`s, so the agent flips offline immediately
|
|
48
|
+
instead of lingering until its heartbeat times out. This is what stops orphaned channel
|
|
49
|
+
processes from fighting over a mailbox.
|
|
50
|
+
|
|
51
|
+
### One live channel per identity
|
|
52
|
+
|
|
53
|
+
Each process takes a per-machine lock on its `AGENT_NAME`. A second concurrent channel with
|
|
54
|
+
the same name doesn't fight for the mailbox — it takes a suffixed **ephemeral** identity
|
|
55
|
+
(`my-agent-2`, `-3`, …) that prunes automatically after 24h. Cross-machine name collisions
|
|
56
|
+
resolve the same way (the relay returns 409, the client re-suffixes and retries).
|
|
57
|
+
|
|
58
|
+
### Prove the relay round-trip
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
npx -y @wastedtokens/agent-switchboard@1.2.0 selftest
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Runs a full register → subscribe (SSE) → send → receive → ack → receipt round-trip through
|
|
65
|
+
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.
|
|
39
67
|
|
|
40
68
|
## Env
|
|
41
69
|
|
|
@@ -43,8 +71,9 @@ In-session: `announce` once, `who_is_online`, `send_message(to, body)`, and answ
|
|
|
43
71
|
|---|---|
|
|
44
72
|
| `SWITCHBOARD_API_KEY` | **required** — your per-user key (`wtsb_…`) |
|
|
45
73
|
| `SWITCHBOARD_URL` | relay base URL (default `https://switchboard.wastedtokens.io`) |
|
|
46
|
-
| `AGENT_NAME` | stable directory identity; omit for an ephemeral anonymous name |
|
|
74
|
+
| `AGENT_NAME` | stable directory identity; omit for an ephemeral anonymous name. A concurrent second process with the same name auto-suffixes (`-2`, `-3`, …) |
|
|
47
75
|
| `AGENT_PROJECT` / `AGENT_MACHINE` / `AGENT_CAPABILITIES` | directory metadata |
|
|
76
|
+
| `SWITCHBOARD_AUTO_ANNOUNCE` | `1` ⇒ register + open the push stream on connect (instant presence, no manual `announce`) |
|
|
48
77
|
|
|
49
78
|
Delivery is at-least-once (the relay redelivers on unacked streams); the client dedupes by
|
|
50
79
|
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.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"type": "module",
|
|
5
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.",
|
|
6
6
|
"license": "UNLICENSED",
|
package/switchboard-channel.mjs
CHANGED
|
@@ -23,9 +23,10 @@
|
|
|
23
23
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|
24
24
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
25
25
|
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
|
26
|
-
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
26
|
+
import { readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs'
|
|
27
27
|
import { homedir, hostname } from 'node:os'
|
|
28
28
|
import { join } from 'node:path'
|
|
29
|
+
import { randomBytes } from 'node:crypto'
|
|
29
30
|
|
|
30
31
|
const URL = (process.env.SWITCHBOARD_URL || 'https://switchboard.wastedtokens.io').replace(/\/+$/, '')
|
|
31
32
|
const API = `${URL}/api/v1`
|
|
@@ -41,9 +42,60 @@ const NAME = process.env.AGENT_NAME || `anon-${hostname()}-${process.pid}`
|
|
|
41
42
|
const PROJECT = process.env.AGENT_PROJECT || process.cwd()
|
|
42
43
|
const MACHINE = process.env.AGENT_MACHINE || hostname()
|
|
43
44
|
const CAPS = (process.env.AGENT_CAPABILITIES || '').split(',').filter(Boolean)
|
|
44
|
-
const ID_CACHE = join(homedir(), '.switchboard', `${NAME}.id`)
|
|
45
45
|
|
|
46
|
-
const
|
|
46
|
+
const SB_DIR = join(homedir(), '.switchboard')
|
|
47
|
+
const idCachePath = (name) => join(SB_DIR, `${name}.id`)
|
|
48
|
+
const lockPath = (name) => join(SB_DIR, `${name}.lock`)
|
|
49
|
+
|
|
50
|
+
const pidAlive = (pid) => {
|
|
51
|
+
try { process.kill(pid, 0); return true } catch (e) { return e.code === 'EPERM' }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const MAX_SUFFIX_HOPS = 8
|
|
55
|
+
|
|
56
|
+
// Take the lock for one name, atomically. Creation is ALWAYS `wx` (exclusive) — a stale
|
|
57
|
+
// (dead-pid) lock is unlinked and creation re-attempted, so when two crash successors
|
|
58
|
+
// race the takeover, exactly one wx-create wins and the loser moves to the next suffix.
|
|
59
|
+
// A takeover win is re-verified by re-reading the file: our fresh lock could itself have
|
|
60
|
+
// been unlinked+recreated by a racer that read the same stale pid a moment before us.
|
|
61
|
+
function tryLock(name) {
|
|
62
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
63
|
+
try {
|
|
64
|
+
writeFileSync(lockPath(name), String(process.pid), { flag: 'wx' })
|
|
65
|
+
} catch {
|
|
66
|
+
let holder = NaN
|
|
67
|
+
try { holder = Number(readFileSync(lockPath(name), 'utf8').trim()) } catch {}
|
|
68
|
+
if (pidAlive(holder)) return false
|
|
69
|
+
try { unlinkSync(lockPath(name)) } catch {}
|
|
70
|
+
continue // stale lock removed — re-attempt the exclusive create (one winner)
|
|
71
|
+
}
|
|
72
|
+
try { if (readFileSync(lockPath(name), 'utf8').trim() === String(process.pid)) return true } catch {}
|
|
73
|
+
return false
|
|
74
|
+
}
|
|
75
|
+
return false
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// One live channel per identity per machine (issue #12): the first process locks NAME;
|
|
79
|
+
// concurrent siblings suffix (NAME-2...) as EPHEMERAL agents — nobody fights over a
|
|
80
|
+
// mailbox stream, which is exactly the tug-of-war that lost messages in the field.
|
|
81
|
+
// `seq` tracks how far down the BASE-derived suffix cascade this identity sits.
|
|
82
|
+
function acquireIdentity() {
|
|
83
|
+
mkdirSync(SB_DIR, { recursive: true })
|
|
84
|
+
for (let n = 0; n < MAX_SUFFIX_HOPS; n++) {
|
|
85
|
+
const name = n === 0 ? NAME : `${NAME}-${n + 1}`
|
|
86
|
+
if (tryLock(name)) return { name, ephemeral: n > 0, seq: n }
|
|
87
|
+
}
|
|
88
|
+
return { name: `${NAME}-${randomBytes(2).toString('hex')}`, ephemeral: true, seq: MAX_SUFFIX_HOPS }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function releaseIdentity(identity) {
|
|
92
|
+
try {
|
|
93
|
+
if (readFileSync(lockPath(identity.name), 'utf8').trim() === String(process.pid))
|
|
94
|
+
unlinkSync(lockPath(identity.name))
|
|
95
|
+
} catch {}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const state = { id: null, subscribing: false, running: true, identity: acquireIdentity(), sseCtl: null }
|
|
47
99
|
|
|
48
100
|
function authHeaders(extra = {}) {
|
|
49
101
|
return { 'Content-Type': 'application/json', Authorization: `Bearer ${KEY}`, ...extra }
|
|
@@ -61,33 +113,133 @@ async function relay(method, path, body) {
|
|
|
61
113
|
return { status: res.status, retryAfter: Number(res.headers.get('retry-after')) || 0, data }
|
|
62
114
|
}
|
|
63
115
|
|
|
64
|
-
|
|
65
|
-
|
|
116
|
+
// CLI selftest (issue #12): a full round-trip through the CONFIGURED relay — register ->
|
|
117
|
+
// subscribe (SSE) -> send -> receive the push -> ack -> receipt — so an operator can prove
|
|
118
|
+
// the edge/proxy path end-to-end before wiring it into a session. Exits 0 (pass) / 1 (fail).
|
|
119
|
+
if (process.argv[2] === 'selftest') {
|
|
120
|
+
const fail = (msg) => { console.error(`SELFTEST FAIL: ${msg}`); process.exit(1) }
|
|
121
|
+
const name = `selftest-${randomBytes(3).toString('hex')}`
|
|
122
|
+
const reg = await relay('POST', '/register', { name, machine: MACHINE, project: 'selftest', presence: 'live', ephemeral: true })
|
|
123
|
+
if (!reg.data.id) fail(`register: http ${reg.status} ${reg.data.error || ''}`)
|
|
124
|
+
console.error(`registered ${name} (${reg.data.id}) against ${URL}`)
|
|
125
|
+
const ctl = new AbortController()
|
|
126
|
+
const sub = await fetch(`${API}/subscribe?agent_id=${reg.data.id}`, { headers: authHeaders(), signal: ctl.signal })
|
|
127
|
+
if (!sub.ok || !sub.body) fail(`subscribe: http ${sub.status}`)
|
|
128
|
+
const send = await relay('POST', '/send', { from: name, to: reg.data.id, body: 'selftest ping', subject: 'selftest' })
|
|
129
|
+
if (!send.data.message_id) fail(`send: http ${send.status} ${send.data.error || ''}`)
|
|
130
|
+
const reader = sub.body.getReader()
|
|
131
|
+
const dec = new TextDecoder()
|
|
132
|
+
let buf = ''
|
|
133
|
+
const deadline = Date.now() + 15000
|
|
134
|
+
let got = null
|
|
135
|
+
while (!got && Date.now() < deadline) {
|
|
136
|
+
const { value, done } = await reader.read()
|
|
137
|
+
if (done) break
|
|
138
|
+
buf += dec.decode(value, { stream: true })
|
|
139
|
+
let idx
|
|
140
|
+
while ((idx = buf.indexOf('\n\n')) >= 0) {
|
|
141
|
+
const block = buf.slice(0, idx); buf = buf.slice(idx + 2)
|
|
142
|
+
if (block.startsWith('data:')) {
|
|
143
|
+
const data = JSON.parse(block.slice(block.indexOf(':') + 1).trim())
|
|
144
|
+
got = (data.messages || []).find((m) => m.id === send.data.message_id) || got
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (!got) fail('message did not arrive over SSE within 15s (edge/proxy problem?)')
|
|
149
|
+
await relay('POST', '/ack', { agent_id: reg.data.id, ids: [got.id] })
|
|
150
|
+
const receipt = await relay('GET', `/messages/${send.data.message_id}`)
|
|
151
|
+
if (receipt.status === 404) {
|
|
152
|
+
// Pre-#12 relay: the receipt endpoint isn't deployed yet. Degrade gracefully — the SSE
|
|
153
|
+
// round-trip + ack above already proved delivery; the strict receipt check switches on
|
|
154
|
+
// automatically once the server ships GET /api/v1/messages/:id.
|
|
155
|
+
console.error('SELFTEST WARN: GET /messages/:id unavailable on this relay (pre-#12 server) — skipping receipt check')
|
|
156
|
+
} else if (receipt.data.state !== 'delivered') {
|
|
157
|
+
fail(`receipt state = ${receipt.data.state}, want delivered`)
|
|
158
|
+
}
|
|
159
|
+
ctl.abort()
|
|
160
|
+
await relay('POST', '/logoff', { id: reg.data.id })
|
|
161
|
+
console.error(`SELFTEST PASS: register -> subscribe -> send -> push -> ack -> receipt, all through ${URL}`)
|
|
162
|
+
process.exit(0)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function loadCachedId(name) {
|
|
166
|
+
try { return readFileSync(idCachePath(name), 'utf8').trim() || null } catch { return null }
|
|
66
167
|
}
|
|
67
|
-
function saveId(id) {
|
|
68
|
-
try { mkdirSync(
|
|
168
|
+
function saveId(name, id) {
|
|
169
|
+
try { mkdirSync(SB_DIR, { recursive: true }); writeFileSync(idCachePath(name), id) } catch {}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Suffix candidates always derive from the BASE name (a base that itself ends in digits,
|
|
173
|
+
// like "agent-7", yields agent-7-2 — never agent-8 or agent-7-2-3): numeric -2..-8, then
|
|
174
|
+
// a random-hex tail. `seq` carries the cascade position across hops.
|
|
175
|
+
//
|
|
176
|
+
// Every candidate is tryLock'd before it's returned (review round 2): a relay-side 409
|
|
177
|
+
// carries no local-lock information, so two local processes hitting the same cross-machine
|
|
178
|
+
// base collision would otherwise BOTH hop to base-2 — same agent row, dueling subscribe
|
|
179
|
+
// loops, the exact tug-of-war this client exists to prevent. Locally-locked candidates
|
|
180
|
+
// are skipped (seq advances past them); the invariant is "never return an identity whose
|
|
181
|
+
// lock this process does not own".
|
|
182
|
+
function nextSuffixIdentity(ident) {
|
|
183
|
+
releaseIdentity(ident)
|
|
184
|
+
let seq = ident.seq ?? 0
|
|
185
|
+
while (++seq < MAX_SUFFIX_HOPS) {
|
|
186
|
+
const name = `${NAME}-${seq + 1}`
|
|
187
|
+
if (tryLock(name)) return { name, ephemeral: true, seq }
|
|
188
|
+
}
|
|
189
|
+
for (let i = 0; i < MAX_SUFFIX_HOPS; i++) {
|
|
190
|
+
const name = `${NAME}-${randomBytes(2).toString('hex')}`
|
|
191
|
+
if (tryLock(name)) return { name, ephemeral: true, seq }
|
|
192
|
+
}
|
|
193
|
+
// Unreachable unless the lock dir itself is unwritable — at that point local locking is
|
|
194
|
+
// broken wholesale and an unlocked random identity beats crashing the session.
|
|
195
|
+
return { name: `${NAME}-${randomBytes(2).toString('hex')}`, ephemeral: true, seq }
|
|
69
196
|
}
|
|
70
197
|
|
|
71
198
|
async function register() {
|
|
199
|
+
const ident = state.identity
|
|
200
|
+
const cached = ident.ephemeral ? null : loadCachedId(ident.name)
|
|
72
201
|
const r = await relay('POST', '/register', {
|
|
73
|
-
id: state.id ||
|
|
74
|
-
machine: MACHINE, capabilities: CAPS, presence: 'live',
|
|
202
|
+
id: state.id || cached, name: ident.name, project: PROJECT,
|
|
203
|
+
machine: MACHINE, capabilities: CAPS, presence: 'live', ephemeral: ident.ephemeral,
|
|
75
204
|
})
|
|
76
|
-
if (r.
|
|
205
|
+
if (r.status === 409) {
|
|
206
|
+
// name owned by another LIVE process (other machine, or a lock we couldn't see):
|
|
207
|
+
// take the next suffix as an ephemeral identity and let the caller retry.
|
|
208
|
+
state.identity = nextSuffixIdentity(ident)
|
|
209
|
+
state.id = null
|
|
210
|
+
return r
|
|
211
|
+
}
|
|
212
|
+
if (r.data.id) { state.id = r.data.id; if (!ident.ephemeral) saveId(ident.name, r.data.id) }
|
|
77
213
|
else state.id = null // registration FAILED — callers must not treat this as registered
|
|
78
214
|
return r
|
|
79
215
|
}
|
|
80
216
|
|
|
217
|
+
// The ONE registration entry point for every caller (announce tool, auto-announce,
|
|
218
|
+
// subscribeLoop, lazy send/reply): a cross-machine live-name collision (server 409)
|
|
219
|
+
// hops to the next base-derived suffix and retries, bounded at MAX_SUFFIX_HOPS hops per
|
|
220
|
+
// call. Exhaustion returns the final 409 so callers fall into their normal failure /
|
|
221
|
+
// 30s-backoff path — a buggy or hostile relay can never spin this client hot.
|
|
222
|
+
async function registerWithSuffixRetry() {
|
|
223
|
+
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)
|
|
226
|
+
console.error('@wastedtokens/agent-switchboard: relay refused every suffixed name (409) — giving up this attempt')
|
|
227
|
+
return r
|
|
228
|
+
}
|
|
229
|
+
|
|
81
230
|
// --- push side: relay SSE -> channel notification into the session ----------
|
|
82
|
-
// The wire is at-least-once (unacked
|
|
231
|
+
// The wire is at-least-once (unacked leases redeliver), so dedupe by msg id here — but
|
|
83
232
|
// an id is only marked seen AFTER the notification write succeeds. A failed write leaves
|
|
84
233
|
// the id unseen so the relay's redelivery re-pushes it (marking first would turn a
|
|
85
234
|
// recoverable failure into silent loss). The set is process-lifetime by design: a crashed
|
|
86
235
|
// channel restarts empty, which is exactly what lets redelivery reach the new process.
|
|
236
|
+
// Returns the msg id when the push succeeded so the caller can ACK the relay — the server
|
|
237
|
+
// only finalizes delivery on our explicit ack (it cannot tell a dead stream from a live
|
|
238
|
+
// one behind the proxy chain).
|
|
87
239
|
const seen = new Set()
|
|
88
240
|
const SEEN_CAP = 1000
|
|
89
241
|
async function pushChannel(server, m) {
|
|
90
|
-
if (m.id && seen.has(m.id)) return
|
|
242
|
+
if (m.id && seen.has(m.id)) return m.id || null // already delivered to the session: re-ack
|
|
91
243
|
try {
|
|
92
244
|
await server.notification({
|
|
93
245
|
method: 'notifications/claude/channel',
|
|
@@ -106,7 +258,10 @@ async function pushChannel(server, m) {
|
|
|
106
258
|
seen.add(m.id)
|
|
107
259
|
if (seen.size > SEEN_CAP) for (const old of seen) { seen.delete(old); if (seen.size <= SEEN_CAP) break }
|
|
108
260
|
}
|
|
109
|
-
|
|
261
|
+
return m.id || null
|
|
262
|
+
} catch {
|
|
263
|
+
return null // not marked seen, not acked -> redelivery will retry
|
|
264
|
+
}
|
|
110
265
|
}
|
|
111
266
|
|
|
112
267
|
async function subscribeLoop(server) {
|
|
@@ -115,10 +270,11 @@ async function subscribeLoop(server) {
|
|
|
115
270
|
while (state.running) {
|
|
116
271
|
try {
|
|
117
272
|
if (!state.id) {
|
|
118
|
-
const r = await
|
|
273
|
+
const r = await registerWithSuffixRetry()
|
|
119
274
|
if (!state.id) {
|
|
120
|
-
// Registration REJECTED (bad name, agent cap, rate limit
|
|
121
|
-
// hit /subscribe without an id, never
|
|
275
|
+
// Registration REJECTED (bad name, agent cap, rate limit, exhausted 409
|
|
276
|
+
// cascade...): back off — never hit /subscribe without an id, never
|
|
277
|
+
// tight-loop against the relay.
|
|
122
278
|
const waitMs = r.status === 429 && r.retryAfter ? r.retryAfter * 1000 : 30_000
|
|
123
279
|
console.error(
|
|
124
280
|
`@wastedtokens/agent-switchboard: registration failed (http ${r.status}): ${r.data.error || 'unknown'} — retrying in ${Math.round(waitMs / 1000)}s`,
|
|
@@ -127,7 +283,8 @@ async function subscribeLoop(server) {
|
|
|
127
283
|
continue
|
|
128
284
|
}
|
|
129
285
|
}
|
|
130
|
-
|
|
286
|
+
state.sseCtl = new AbortController()
|
|
287
|
+
const res = await fetch(`${API}/subscribe?agent_id=${state.id}`, { headers: authHeaders(), signal: state.sseCtl.signal })
|
|
131
288
|
if (res.status === 404) { state.id = null; continue } // next iteration re-registers (with backoff on failure)
|
|
132
289
|
if (res.status === 429) { await sleep((Number(res.headers.get('retry-after')) || 30) * 1000); continue }
|
|
133
290
|
if (!res.ok || !res.body) { await sleep(2000); continue }
|
|
@@ -144,7 +301,13 @@ async function subscribeLoop(server) {
|
|
|
144
301
|
if (block.startsWith('data:')) {
|
|
145
302
|
try {
|
|
146
303
|
const data = JSON.parse(block.slice(block.indexOf(':') + 1).trim())
|
|
147
|
-
|
|
304
|
+
const acks = []
|
|
305
|
+
for (const m of data.messages || []) {
|
|
306
|
+
const acked = await pushChannel(server, m)
|
|
307
|
+
if (acked) acks.push(acked)
|
|
308
|
+
}
|
|
309
|
+
if (acks.length && state.id)
|
|
310
|
+
relay('POST', '/ack', { agent_id: state.id, ids: acks }).catch(() => {})
|
|
148
311
|
} catch {}
|
|
149
312
|
}
|
|
150
313
|
}
|
|
@@ -161,7 +324,7 @@ const fail = (obj) => ({ isError: true, content: [{ type: 'text', text: JSON.str
|
|
|
161
324
|
|
|
162
325
|
// --- MCP server + tools -----------------------------------------------------
|
|
163
326
|
const server = new Server(
|
|
164
|
-
{ name: 'switchboard', version: '1.
|
|
327
|
+
{ name: 'switchboard', version: '1.2.0' },
|
|
165
328
|
{
|
|
166
329
|
capabilities: { experimental: { 'claude/channel': {} }, tools: {} },
|
|
167
330
|
instructions:
|
|
@@ -192,6 +355,8 @@ const TOOLS = [
|
|
|
192
355
|
to: { type: 'string' }, thread: { type: 'string' }, body: { type: 'string' },
|
|
193
356
|
in_reply_to: { type: 'string', description: 'optional msg_id being answered' },
|
|
194
357
|
}, required: ['to', 'thread', 'body'] } },
|
|
358
|
+
{ name: 'message_status', description: 'Delivery receipt for a message you sent: queued | leased | delivered | expired.',
|
|
359
|
+
inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'message id from send_message' } }, required: ['id'] } },
|
|
195
360
|
{ name: 'logoff', description: 'Go offline now and stop receiving pushes.',
|
|
196
361
|
inputSchema: { type: 'object', properties: {} } },
|
|
197
362
|
]
|
|
@@ -202,26 +367,30 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
202
367
|
const a = req.params.arguments || {}
|
|
203
368
|
switch (req.params.name) {
|
|
204
369
|
case 'announce': {
|
|
205
|
-
const r = await
|
|
370
|
+
const r = await registerWithSuffixRetry()
|
|
206
371
|
if (!state.id) return fail({ error: `registration failed: ${r.data.error || `http ${r.status}`}` })
|
|
207
372
|
subscribeLoop(server) // fire-and-forget background push loop
|
|
208
373
|
return ok((await relay('GET', '/directory')).data)
|
|
209
374
|
}
|
|
210
375
|
case 'who_is_online':
|
|
211
376
|
return ok((await relay('GET', '/directory')).data)
|
|
377
|
+
case 'message_status':
|
|
378
|
+
return ok((await relay('GET', `/messages/${encodeURIComponent(a.id)}`)).data)
|
|
212
379
|
case 'send_message':
|
|
213
|
-
if (!state.id) await
|
|
380
|
+
if (!state.id) await registerWithSuffixRetry()
|
|
214
381
|
return ok((await relay('POST', '/send', {
|
|
215
|
-
from:
|
|
382
|
+
from: state.identity.name, to: a.to, body: a.body, subject: a.subject || '',
|
|
216
383
|
thread: a.thread || null,
|
|
217
384
|
})).data)
|
|
218
385
|
case 'reply':
|
|
219
|
-
if (!state.id) await
|
|
386
|
+
if (!state.id) await registerWithSuffixRetry()
|
|
220
387
|
return ok((await relay('POST', '/send', {
|
|
221
|
-
from:
|
|
388
|
+
from: state.identity.name, to: a.to, body: a.body, thread: a.thread, in_reply_to: a.in_reply_to || null,
|
|
222
389
|
})).data)
|
|
223
390
|
case 'logoff': {
|
|
224
391
|
state.running = false
|
|
392
|
+
try { state.sseCtl?.abort() } catch {}
|
|
393
|
+
releaseIdentity(state.identity)
|
|
225
394
|
const r = state.id ? (await relay('POST', '/logoff', { id: state.id })).data : { ok: true }
|
|
226
395
|
return ok(r)
|
|
227
396
|
}
|
|
@@ -230,4 +399,28 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
230
399
|
}
|
|
231
400
|
})
|
|
232
401
|
|
|
402
|
+
// Auto-signoff (issue #12): a dying session must not leave an orphan consumer — orphans
|
|
403
|
+
// are what created the zombie tug-of-war in the field. stdin EOF IS session death for a
|
|
404
|
+
// stdio MCP server; signals cover kills; the SSE abort drops the stream server-side so
|
|
405
|
+
// eviction/hand-back runs immediately.
|
|
406
|
+
let shuttingDown = false
|
|
407
|
+
async function shutdown() {
|
|
408
|
+
if (shuttingDown) return
|
|
409
|
+
shuttingDown = true
|
|
410
|
+
state.running = false
|
|
411
|
+
try { state.sseCtl?.abort() } catch {}
|
|
412
|
+
releaseIdentity(state.identity)
|
|
413
|
+
if (state.id) await Promise.race([relay('POST', '/logoff', { id: state.id }), sleep(2000)]).catch(() => {})
|
|
414
|
+
process.exit(0)
|
|
415
|
+
}
|
|
416
|
+
process.stdin.on('end', shutdown)
|
|
417
|
+
process.stdin.on('close', shutdown)
|
|
418
|
+
for (const sig of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(sig, shutdown)
|
|
419
|
+
|
|
233
420
|
await server.connect(new StdioServerTransport())
|
|
421
|
+
|
|
422
|
+
// Auto-announce (issue #12): opt-in instant presence — register + open the push stream on
|
|
423
|
+
// connect, so a session is reachable without the operator remembering to call `announce`.
|
|
424
|
+
if (process.env.SWITCHBOARD_AUTO_ANNOUNCE === '1') {
|
|
425
|
+
registerWithSuffixRetry().then(() => { if (state.id) subscribeLoop(server) }).catch(() => {})
|
|
426
|
+
}
|