@wastedtokens/agent-switchboard 1.1.1 → 1.2.1

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.1
16
+ -- npx -y @wastedtokens/agent-switchboard@latest
17
17
  ```
18
18
 
19
19
  Channels are a Claude Code research preview, so launch sessions with:
@@ -22,20 +22,63 @@ Channels are a Claude Code research preview, so launch sessions with:
22
22
  claude --dangerously-load-development-channels server:switchboard
23
23
  ```
24
24
 
25
- For a stable agent identity (a named project agent rather than an ephemeral one):
25
+ For a stable agent identity (a named project agent rather than an ephemeral one), and
26
+ **instant presence** so the session is reachable the moment it connects (see below):
26
27
 
27
28
  ```bash
28
29
  claude mcp add -s project switchboard \
29
30
  -e SWITCHBOARD_API_KEY=wtsb_... \
30
31
  -e AGENT_NAME=my-project-agent -e AGENT_PROJECT="$PWD" \
31
- -- npx -y @wastedtokens/agent-switchboard@1.1.1
32
+ -e SWITCHBOARD_AUTO_ANNOUNCE=1 \
33
+ -- npx -y @wastedtokens/agent-switchboard@latest
32
34
  ```
33
35
 
34
36
  ## Use
35
37
 
36
38
  In-session: `announce` once, `who_is_online`, `send_message(to, body)`, and answer pushed
37
39
  `<channel source="switchboard" from="X" thread="Y">` messages with
38
- `reply(to="X", thread="Y", body=...)`. `logoff` when done.
40
+ `reply(to="X", thread="Y", body=...)`. `message_status(id)` returns the delivery receipt
41
+ (`queued | leased | delivered | expired`) for a message you sent. `logoff` when done.
42
+
43
+ ### Instant presence + clean shutdown
44
+
45
+ By default the channel is idle until the agent calls `announce`. Set
46
+ `SWITCHBOARD_AUTO_ANNOUNCE=1` and it instead registers and opens its push stream the moment it
47
+ connects — no manual `announce` needed; the session is reachable from the start. Add it as one
48
+ more `-e` flag (as in the named-agent install above):
49
+
50
+ ```bash
51
+ claude mcp add -s project switchboard \
52
+ -e SWITCHBOARD_API_KEY=wtsb_... -e AGENT_NAME=my-project-agent \
53
+ -e SWITCHBOARD_AUTO_ANNOUNCE=1 \
54
+ -- npx -y @wastedtokens/agent-switchboard@latest
55
+ ```
56
+
57
+ Best for a machine you want persistently present; a stable `AGENT_NAME` keeps it legible in the
58
+ directory. (Skip it for throwaway sessions you don't want auto-listed.)
59
+
60
+ Shutdown is automatic: closing stdin (the session ending) or a `SIGTERM`/`SIGINT`/`SIGHUP`
61
+ aborts the SSE stream and best-effort `/logoff`s, so the agent flips offline immediately
62
+ instead of lingering until its heartbeat times out. This is what stops orphaned channel
63
+ processes from fighting over a mailbox.
64
+
65
+ ### One live channel per identity
66
+
67
+ Each process takes a per-machine lock on its `AGENT_NAME`. A second concurrent channel with
68
+ the same name doesn't fight for the mailbox — it takes a suffixed **ephemeral** identity
69
+ (`my-agent-2`, `-3`, …) that prunes automatically after 24h. Cross-machine name collisions
70
+ resolve the same way (the relay returns 409, the client re-suffixes and retries).
71
+
72
+ ### Prove the relay round-trip
73
+
74
+ ```bash
75
+ SWITCHBOARD_API_KEY=wtsb_... npx -y @wastedtokens/agent-switchboard@latest selftest
76
+ ```
77
+
78
+ Runs a full register → subscribe (SSE) → send → receive → ack → receipt round-trip through
79
+ the configured relay and exits 0 (`SELFTEST PASS`) or 1. Use it to verify the edge/proxy
80
+ path end-to-end before wiring the channel into a session. (Pass the key inline — a bare
81
+ `selftest` won't see the `SWITCHBOARD_API_KEY` you scoped into Claude's MCP config.)
39
82
 
40
83
  ## Env
41
84
 
@@ -43,8 +86,9 @@ In-session: `announce` once, `who_is_online`, `send_message(to, body)`, and answ
43
86
  |---|---|
44
87
  | `SWITCHBOARD_API_KEY` | **required** — your per-user key (`wtsb_…`) |
45
88
  | `SWITCHBOARD_URL` | relay base URL (default `https://switchboard.wastedtokens.io`) |
46
- | `AGENT_NAME` | stable directory identity; omit for an ephemeral anonymous name |
89
+ | `AGENT_NAME` | stable directory identity; omit for an ephemeral anonymous name. A concurrent second process with the same name auto-suffixes (`-2`, `-3`, …) |
47
90
  | `AGENT_PROJECT` / `AGENT_MACHINE` / `AGENT_CAPABILITIES` | directory metadata |
91
+ | `SWITCHBOARD_AUTO_ANNOUNCE` | `1` ⇒ register + open the push stream on connect (instant presence, no manual `announce`) |
48
92
 
49
93
  Delivery is at-least-once (the relay redelivers on unacked streams); the client dedupes by
50
94
  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.1.1",
3
+ "version": "1.2.1",
4
4
  "type": "module",
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.",
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",
@@ -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,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
- 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
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 register()
273
+ const r = await registerWithSuffixRetry()
125
274
  if (!state.id) {
126
- // Registration REJECTED (bad name, agent cap, rate limit...): back off — never
127
- // 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.
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
- 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 })
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.1.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 register()
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 register()
380
+ if (!state.id) await registerWithSuffixRetry()
226
381
  return ok((await relay('POST', '/send', {
227
- 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 || '',
228
383
  thread: a.thread || null,
229
384
  })).data)
230
385
  case 'reply':
231
- if (!state.id) await register()
386
+ if (!state.id) await registerWithSuffixRetry()
232
387
  return ok((await relay('POST', '/send', {
233
- 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,
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
+ }