@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 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.1.0
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.1.0
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=...)`. `logoff` when done.
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.1.0",
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",
@@ -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 state = { id: null, subscribing: false, running: true }
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
- function loadCachedId() {
65
- try { return readFileSync(ID_CACHE, 'utf8').trim() || null } catch { return null }
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(join(homedir(), '.switchboard'), { recursive: true }); writeFileSync(ID_CACHE, id) } catch {}
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 || loadCachedId(), name: NAME, project: PROJECT,
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.data.id) { state.id = r.data.id; saveId(r.data.id) }
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 streams redeliver), so dedupe by msg id here — but
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
- } catch {} // not marked seen -> redelivery will retry
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 register()
273
+ const r = await registerWithSuffixRetry()
119
274
  if (!state.id) {
120
- // Registration REJECTED (bad name, agent cap, rate limit...): back off — never
121
- // hit /subscribe without an id, never tight-loop against the relay.
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
- const res = await fetch(`${API}/subscribe?agent_id=${state.id}`, { headers: authHeaders() })
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
- for (const m of data.messages || []) await pushChannel(server, m)
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.1.0' },
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 register()
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 register()
380
+ if (!state.id) await registerWithSuffixRetry()
214
381
  return ok((await relay('POST', '/send', {
215
- from: NAME, to: a.to, body: a.body, subject: a.subject || '',
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 register()
386
+ if (!state.id) await registerWithSuffixRetry()
220
387
  return ok((await relay('POST', '/send', {
221
- from: NAME, to: a.to, body: a.body, thread: a.thread, in_reply_to: a.in_reply_to || null,
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
+ }