@wastedtokens/agent-switchboard 1.1.1 → 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 +201 -20
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,23 +113,120 @@ 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
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
|
|
@@ -121,10 +270,11 @@ async function subscribeLoop(server) {
|
|
|
121
270
|
while (state.running) {
|
|
122
271
|
try {
|
|
123
272
|
if (!state.id) {
|
|
124
|
-
const r = await
|
|
273
|
+
const r = await registerWithSuffixRetry()
|
|
125
274
|
if (!state.id) {
|
|
126
|
-
// Registration REJECTED (bad name, agent cap, rate limit
|
|
127
|
-
// 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.
|
|
128
278
|
const waitMs = r.status === 429 && r.retryAfter ? r.retryAfter * 1000 : 30_000
|
|
129
279
|
console.error(
|
|
130
280
|
`@wastedtokens/agent-switchboard: registration failed (http ${r.status}): ${r.data.error || 'unknown'} — retrying in ${Math.round(waitMs / 1000)}s`,
|
|
@@ -133,7 +283,8 @@ async function subscribeLoop(server) {
|
|
|
133
283
|
continue
|
|
134
284
|
}
|
|
135
285
|
}
|
|
136
|
-
|
|
286
|
+
state.sseCtl = new AbortController()
|
|
287
|
+
const res = await fetch(`${API}/subscribe?agent_id=${state.id}`, { headers: authHeaders(), signal: state.sseCtl.signal })
|
|
137
288
|
if (res.status === 404) { state.id = null; continue } // next iteration re-registers (with backoff on failure)
|
|
138
289
|
if (res.status === 429) { await sleep((Number(res.headers.get('retry-after')) || 30) * 1000); continue }
|
|
139
290
|
if (!res.ok || !res.body) { await sleep(2000); continue }
|
|
@@ -173,7 +324,7 @@ const fail = (obj) => ({ isError: true, content: [{ type: 'text', text: JSON.str
|
|
|
173
324
|
|
|
174
325
|
// --- MCP server + tools -----------------------------------------------------
|
|
175
326
|
const server = new Server(
|
|
176
|
-
{ name: 'switchboard', version: '1.
|
|
327
|
+
{ name: 'switchboard', version: '1.2.0' },
|
|
177
328
|
{
|
|
178
329
|
capabilities: { experimental: { 'claude/channel': {} }, tools: {} },
|
|
179
330
|
instructions:
|
|
@@ -204,6 +355,8 @@ const TOOLS = [
|
|
|
204
355
|
to: { type: 'string' }, thread: { type: 'string' }, body: { type: 'string' },
|
|
205
356
|
in_reply_to: { type: 'string', description: 'optional msg_id being answered' },
|
|
206
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'] } },
|
|
207
360
|
{ name: 'logoff', description: 'Go offline now and stop receiving pushes.',
|
|
208
361
|
inputSchema: { type: 'object', properties: {} } },
|
|
209
362
|
]
|
|
@@ -214,26 +367,30 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
214
367
|
const a = req.params.arguments || {}
|
|
215
368
|
switch (req.params.name) {
|
|
216
369
|
case 'announce': {
|
|
217
|
-
const r = await
|
|
370
|
+
const r = await registerWithSuffixRetry()
|
|
218
371
|
if (!state.id) return fail({ error: `registration failed: ${r.data.error || `http ${r.status}`}` })
|
|
219
372
|
subscribeLoop(server) // fire-and-forget background push loop
|
|
220
373
|
return ok((await relay('GET', '/directory')).data)
|
|
221
374
|
}
|
|
222
375
|
case 'who_is_online':
|
|
223
376
|
return ok((await relay('GET', '/directory')).data)
|
|
377
|
+
case 'message_status':
|
|
378
|
+
return ok((await relay('GET', `/messages/${encodeURIComponent(a.id)}`)).data)
|
|
224
379
|
case 'send_message':
|
|
225
|
-
if (!state.id) await
|
|
380
|
+
if (!state.id) await registerWithSuffixRetry()
|
|
226
381
|
return ok((await relay('POST', '/send', {
|
|
227
|
-
from:
|
|
382
|
+
from: state.identity.name, to: a.to, body: a.body, subject: a.subject || '',
|
|
228
383
|
thread: a.thread || null,
|
|
229
384
|
})).data)
|
|
230
385
|
case 'reply':
|
|
231
|
-
if (!state.id) await
|
|
386
|
+
if (!state.id) await registerWithSuffixRetry()
|
|
232
387
|
return ok((await relay('POST', '/send', {
|
|
233
|
-
from:
|
|
388
|
+
from: state.identity.name, to: a.to, body: a.body, thread: a.thread, in_reply_to: a.in_reply_to || null,
|
|
234
389
|
})).data)
|
|
235
390
|
case 'logoff': {
|
|
236
391
|
state.running = false
|
|
392
|
+
try { state.sseCtl?.abort() } catch {}
|
|
393
|
+
releaseIdentity(state.identity)
|
|
237
394
|
const r = state.id ? (await relay('POST', '/logoff', { id: state.id })).data : { ok: true }
|
|
238
395
|
return ok(r)
|
|
239
396
|
}
|
|
@@ -242,4 +399,28 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
242
399
|
}
|
|
243
400
|
})
|
|
244
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
|
+
|
|
245
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
|
+
}
|