openvisio-agent 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,6 +20,31 @@ That's it. Your agent now has the team's tools (channels, tickets, docs, search)
20
20
 
21
21
  Options: `--name "<agent>"` (label), `--mcp-url <url>` (override the MCP endpoint).
22
22
 
23
+ ### `connect --backend <url> --key <api-key> --id <identifier>`
24
+
25
+ For an agent created against the **org backend** (OpenVisio → Agents → Connect your agent). Verifies the credentials against the backend, then saves `~/.openvisio/<agent>.json` plus a sourceable `~/.openvisio/<agent>.env`:
26
+
27
+ ```
28
+ npx -y openvisio-agent@latest connect --backend https://api.your-org.example/dev \
29
+ --key 660e8400-… --id 550e8400-… --name "Ada"
30
+ ```
31
+
32
+ The api-key is shown **once**, when the agent is created — if it's lost, remove the agent in OpenVisio and connect it again. The backend dispatches board tasks to the agent; every request authenticates with the `x-agent-api-key` + `x-agent-identifier` headers. Options: `--name "<agent>"` (label / file names).
33
+
34
+ **Real-time autonomy (optional).** Add `--ws` and `--mcp-url` to let the agent react to assignments and @mentions the moment the backend pushes them:
35
+
36
+ ```
37
+ npx -y openvisio-agent@latest connect --backend https://api.your-org.example/dev \
38
+ --key 660e8400-… --id 550e8400-… --name "Ada" \
39
+ --ws wss://abc123.execute-api.us-east-1.amazonaws.com/dev \
40
+ --mcp-url https://api.your-org.example/dev/agent/mcp
41
+ ```
42
+
43
+ - `--ws <wss-url>` — the org's API-Gateway WebSocket base (the same value the frontend uses as `NEXT_PUBLIC_BACKEND_WS_URL`).
44
+ - `--mcp-url <url>` — registers the `openvisio-team` MCP (authed with the agent header pair) so the agent has tools to **act** on the events.
45
+
46
+ Then `watch --name ada` auto-detects the backend agent and runs a WebSocket loop instead of polling: it connects with `?api_key=&identifier=`, keeps the connection warm with keepalives, reconnects with backoff, and pokes **one** Claude cycle per `task:assigned` / `agent:mention` (a burst of mentions coalesces into a single follow-up). Needs **Node ≥ 21** for the built-in WebSocket (Node 20: run with `--experimental-websocket`).
47
+
23
48
  ### `watch --name <agent>`
24
49
 
25
50
  Runs the **autonomy loop** — the agent replies to @mentions and picks up tickets on its own. It cheaply polls an inbox endpoint (no model spend when idle) and pokes a single warm Claude Code session only when something new arrives.
package/bin/cli.mjs CHANGED
@@ -10,10 +10,10 @@
10
10
  // config for `watch`. Nothing is fetched-and-eval'd; the whole source is on npm.
11
11
 
12
12
  import { spawnSync } from 'node:child_process'
13
- import { readFileSync } from 'node:fs'
13
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
14
14
  import { fileURLToPath } from 'node:url'
15
15
  import { dirname, join } from 'node:path'
16
- import { parseFlags, slugify, stripSlash, exchangeToken, ensureClaude, writeJson, mcpConfigPath, configPath, fail, ok, info } from '../src/lib.mjs'
16
+ import { parseFlags, slugify, stripSlash, exchangeToken, ensureClaude, writeJson, mcpConfigPath, configPath, chmodSafe, OV_DIR, fail, ok, info } from '../src/lib.mjs'
17
17
  import { runWatch } from '../src/watch.mjs'
18
18
 
19
19
  const HERE = dirname(fileURLToPath(import.meta.url))
@@ -25,6 +25,7 @@ Connect your coding agent to an OpenVisio team.
25
25
 
26
26
  Usage:
27
27
  openvisio-agent connect <ovs_code> --host <url> [--name "<agent>"] [--mcp-url <url>]
28
+ openvisio-agent connect --backend <url> --key <api-key> --id <identifier> [--name "<agent>"] [--ws <wss-url>] [--mcp-url <url>]
28
29
  openvisio-agent watch --name <agent> [--install] [--workdir <repo>]
29
30
  openvisio-agent --help | --version
30
31
 
@@ -32,6 +33,17 @@ connect
32
33
  Redeems the setup code, adds the "openvisio-team" MCP server to Claude Code, and
33
34
  saves a config for the autonomy watcher.
34
35
 
36
+ connect --backend
37
+ Registers a BACKEND agent (created in OpenVisio → Agents → Connect your agent):
38
+ verifies the api-key + identifier against the org backend, then saves the
39
+ credentials (~/.openvisio/<agent>.json + a sourceable <agent>.env). The backend
40
+ dispatches board tasks to the agent; requests authenticate with the
41
+ x-agent-api-key + x-agent-identifier headers.
42
+ --ws <wss-url> API-Gateway WebSocket base (same as NEXT_PUBLIC_BACKEND_WS_URL)
43
+ — enables real-time autonomy (task:assigned / agent:mention).
44
+ --mcp-url <url> registers the openvisio-team MCP so the agent has tools to ACT
45
+ on those events. Needs Node >= 21 for the WebSocket.
46
+
35
47
  watch
36
48
  Runs the event-driven autonomy loop (reply to mentions, pick up tickets). Add
37
49
  --install to run it in the background on login. Add --workdir <repo> to let it do
@@ -40,9 +52,10 @@ watch
40
52
  Docs: https://www.npmjs.com/package/openvisio-agent`
41
53
 
42
54
  async function runConnect({ positional, flags }) {
55
+ if (flags.backend) return runConnectBackend({ positional, flags })
43
56
  const token = positional[0] || flags.token
44
57
  const host = flags.host && String(flags.host)
45
- if (!token) fail('Missing setup code.\n Usage: openvisio-agent connect <ovs_code> --host <your OpenVisio URL>')
58
+ if (!token) fail('Missing setup code.\n Usage: openvisio-agent connect <ovs_code> --host <your OpenVisio URL>\n (Backend agent instead? openvisio-agent connect --backend <url> --key <api-key> --id <identifier>)')
46
59
  if (!/^ovs_[a-z0-9]+$/i.test(String(token))) fail('That doesn\'t look like a setup code (expected ovs_…).')
47
60
  if (!host) fail('Missing --host <your OpenVisio URL>.')
48
61
 
@@ -72,6 +85,86 @@ async function runConnect({ positional, flags }) {
72
85
  info(` openvisio-agent watch --name ${slug} --workdir <repo> # allow real coding on a branch`)
73
86
  }
74
87
 
88
+ // Backend mode — for agents created against the OpenVisio ORG BACKEND
89
+ // ("Connect your agent" in the Agents tool). No setup-token exchange: the
90
+ // one-time api-key + identifier from the create screen ARE the credentials.
91
+ // We verify them live, then persist a config + a sourceable env file.
92
+ async function runConnectBackend({ flags }) {
93
+ const backend = stripSlash(String(flags.backend))
94
+ const apiKey = flags.key && String(flags.key)
95
+ const identifier = flags.id && String(flags.id)
96
+ if (!/^https?:\/\//.test(backend)) fail('The --backend value must be a full URL (e.g. https://api.your-org.example/dev).')
97
+ if (!apiKey || apiKey === true) fail('Missing --key <api-key>.\n The key is shown ONCE when the agent is created (OpenVisio → Agents → Connect your agent).')
98
+ if (!identifier) fail('Missing --id <identifier> (shown on the agent\'s create screen and profile).')
99
+
100
+ info(`Verifying credentials against ${backend} …`)
101
+ const headers = { accept: 'application/json', 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier }
102
+ let res
103
+ try {
104
+ res = await fetch(backend + '/projects', { headers })
105
+ } catch (e) {
106
+ fail(`Couldn't reach ${backend} — check the URL and your connection.\n (${e && e.message ? e.message : e})`)
107
+ }
108
+ if (res.status === 401 || res.status === 403) {
109
+ fail('The backend rejected these credentials.\n Check the api-key and identifier — the key is shown once at creation; if it\'s lost, remove the agent in OpenVisio and connect it again for a fresh one.')
110
+ }
111
+ if (!res.ok) fail(`The backend answered HTTP ${res.status} — credentials could not be verified.`)
112
+
113
+ const name = (flags.name && String(flags.name)) || 'backend-agent'
114
+ const slug = slugify(name)
115
+
116
+ // Optional real-time autonomy: --ws is the org's API-Gateway WS base (same value
117
+ // as the frontend's NEXT_PUBLIC_BACKEND_WS_URL). --mcp-url gives the agent a tool
118
+ // surface so WS events (task:assigned / agent:mention) can drive a Claude cycle.
119
+ const wsUrl = flags.ws ? stripSlash(String(flags.ws)) : ''
120
+ const mcpUrl = flags['mcp-url'] ? String(flags['mcp-url']) : ''
121
+ if (wsUrl && !/^wss?:\/\//.test(wsUrl)) fail('The --ws value must be a WebSocket URL (wss://<id>.execute-api.<region>.amazonaws.com/<stage>).')
122
+
123
+ let mcpConfig = ''
124
+ if (mcpUrl) {
125
+ const claude = ensureClaude()
126
+ // Backend agents authenticate with the agent header pair, not a Bearer JWT.
127
+ const hdr = ['--header', `x-agent-api-key: ${apiKey}`, '--header', `x-agent-identifier: ${identifier}`]
128
+ spawnSync(claude, ['mcp', 'add', '--transport', 'http', 'openvisio-team', mcpUrl, ...hdr], { stdio: 'ignore' })
129
+ mcpConfig = mcpConfigPath(slug)
130
+ writeJson(mcpConfig, { mcpServers: { 'openvisio-team': { type: 'http', url: mcpUrl, headers: { 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier } } } }, true)
131
+ }
132
+
133
+ writeJson(configPath(slug), { mode: 'backend', backend, apiKey, identifier, name, slug, wsUrl, mcpUrl, mcpConfig }, true)
134
+ // A sourceable env file, matching the setup snippet OpenVisio shows.
135
+ const envPath = join(OV_DIR, `${slug}.env`)
136
+ mkdirSync(OV_DIR, { recursive: true })
137
+ writeFileSync(envPath, [
138
+ `OPENVISIO_API_URL=${backend}`,
139
+ `OPENVISIO_AGENT_API_KEY=${apiKey}`,
140
+ `OPENVISIO_AGENT_IDENTIFIER=${identifier}`,
141
+ ...(wsUrl ? [`OPENVISIO_AGENT_WS_URL=${wsUrl}`] : []),
142
+ '',
143
+ ].join('\n'))
144
+ chmodSafe(envPath, 0o600)
145
+
146
+ ok(`"${name}" is connected to the org backend — credentials verified.`)
147
+ info()
148
+ info(`Saved: ${configPath(slug)}`)
149
+ info(` ${envPath} (load with: set -a; source ${envPath}; set +a)`)
150
+ if (mcpConfig) info(` ${mcpConfig} (scoped MCP for the watcher)`)
151
+ info()
152
+ info('The backend dispatches board tasks assigned to this agent. Every request it')
153
+ info('makes authenticates with the x-agent-api-key + x-agent-identifier headers.')
154
+ if (wsUrl) {
155
+ info()
156
+ info('Real-time autonomy is ready. Let it react to assignments + @mentions live:')
157
+ info(` openvisio-agent watch --name ${slug} # run now, in this terminal`)
158
+ info(` openvisio-agent watch --name ${slug} --install # background, on login`)
159
+ info(` openvisio-agent watch --name ${slug} --workdir <repo> # allow real coding on a branch`)
160
+ if (!mcpUrl) info(' (add --mcp-url on connect to give the agent tools to ACT on those events.)')
161
+ } else {
162
+ info()
163
+ info('Tip: pass --ws <wss-url> and --mcp-url <url> to enable real-time autonomy')
164
+ info(' (the agent reacts to task:assigned / agent:mention over a WebSocket).')
165
+ }
166
+ }
167
+
75
168
  async function main() {
76
169
  const argv = process.argv.slice(2)
77
170
  if (argv.includes('--version') || argv[0] === 'version') { info(VERSION); return }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Connect your coding agent (Claude Code) to an OpenVisio team — MCP tools + optional autonomy — in one command. No shell scripts.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/watch.mjs CHANGED
@@ -9,6 +9,7 @@ import { writeFileSync, mkdirSync } from 'node:fs'
9
9
  import { homedir } from 'node:os'
10
10
  import { join, dirname } from 'node:path'
11
11
  import { OV_DIR, readConfig, onPath, fail, ok, info, slugify, stripSlash, chmodSafe } from './lib.mjs'
12
+ import { connectAgentWs, assertWebSocket } from './ws.mjs'
12
13
 
13
14
  const CYCLE = 'Run one OpenVisio autonomy cycle.'
14
15
  const CYCLE_FAST = 'New chat activity in OpenVisio. Call poll_inbox once, then handle items in .mentions and .followUps ONLY (ignore .tasks/.claimable). Post AT MOST ONE reply per channel: if the same person sent several nudges (e.g. repeated @mentions or "status?"), answer them together in ONE post — never reply once per message. CRITICAL HONESTY RULE: your only tools are the openvisio-team chat/ticket tools. You CANNOT write code, read or clone a repo, or add entries to the API-testing/docs/sheets/flowchart tools — you have no tool for any of that. So NEVER say "On it" or "I will do it" for such work. If asked to do something you have no tool for, say plainly in one short message that you cannot do it yourself and what a human would need to do (or offer to file/track a ticket). Do not invent repo names or progress. Reply in 1-3 sentences, no summary; or react_message to dismiss. Then stop.'
@@ -27,41 +28,47 @@ const SESSION_IDLE_MS = 1200000
27
28
  export async function runWatch({ flags }) {
28
29
  const slug = flags.name ? slugify(String(flags.name)) : null
29
30
  const saved = slug ? readConfig(slug) : null
30
- const host = stripSlash(flags.host || (saved && saved.host) || '')
31
- const key = String(flags.key || (saved && saved.key) || '')
32
31
  const claude = String(flags.claude || onPath('claude') || 'claude')
33
32
  const mcpConfig = String(flags['mcp-config'] || (saved && saved.mcpConfig) || '')
34
33
  const workdir = flags.workdir === true ? process.cwd() : (flags.workdir ? String(flags.workdir) : '')
35
34
 
35
+ // Backend agents (connect --backend) drive autonomy over a real-time WS instead
36
+ // of REST-polling the frontend relay. Detected by the saved mode / a --ws flag.
37
+ const backendMode = (saved && saved.mode === 'backend') || !!flags.ws
38
+ if (backendMode) {
39
+ const wsUrl = stripSlash(flags.ws || (saved && saved.wsUrl) || '')
40
+ const apiKey = String(flags.key || (saved && saved.apiKey) || '')
41
+ const identifier = String(flags.id || (saved && saved.identifier) || '')
42
+ if (!wsUrl) fail('No WebSocket URL for that backend agent.\n Re-run `openvisio-agent connect --backend <url> --key <k> --id <id> --ws <wss-url>`, or pass --ws.')
43
+ if (!apiKey || !identifier) fail('No saved backend credentials for that agent.\n Run `openvisio-agent connect --backend …` first, or pass --key and --id.')
44
+ assertWebSocket(fail)
45
+ if (flags.install) return installService({ slug: slug || 'openvisio', claude, mcpConfig, workdir })
46
+ return loopBackendWs({ wsUrl, apiKey, identifier, claude, mcpConfig, workdir })
47
+ }
48
+
49
+ const host = stripSlash(flags.host || (saved && saved.host) || '')
50
+ const key = String(flags.key || (saved && saved.key) || '')
36
51
  if (!host || !key) fail('No saved connection for that agent.\n Run `openvisio-agent connect <ovs_code> --host <url> --name <agent>` first, or pass --host and --key.')
37
52
 
38
- if (flags.install) return installService({ slug: slug || 'openvisio', host, key, claude, mcpConfig, workdir })
53
+ if (flags.install) return installService({ slug: slug || 'openvisio', claude, mcpConfig, workdir })
39
54
 
40
55
  return loop({ host, key, claude, mcpConfig, workdir })
41
56
  }
42
57
 
43
- // ── the warm loop ────────────────────────────────────────────────────────────
44
- function loop({ host, key, claude, mcpConfig, workdir }) {
58
+ // ── Claude Code warm-session cycle runner (shared by the REST + WS loops) ─────
59
+ // One persistent stream-json session, poked with a prompt per cycle. Recycled
60
+ // after MAX_TURNS or SESSION_IDLE_MS. Returns { runCycle, canCode }.
61
+ function createCycleRunner({ claude, mcpConfig, workdir, log }) {
45
62
  const canCode = !!workdir
46
- const fullPrompt = canCode ? CODE_FULL : CYCLE
47
- const fastPrompt = canCode ? CODE_FAST : CYCLE_FAST
48
-
49
- const seen = new Set()
50
- let busy = false
51
- let firstCheck = true
52
- let lastNewAt = Date.now()
53
-
54
63
  let child = null
55
64
  let turnsThisSession = 0
56
65
  let sessionStartedAt = 0
57
66
  let resolveTurn = null
58
-
59
- const log = (m) => process.stdout.write('[warm ' + new Date().toISOString() + '] ' + m + '\n')
60
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
61
67
  const settleTurn = (o) => { const r = resolveTurn; resolveTurn = null; if (r) r(o) }
62
68
 
63
69
  function ensureSession() {
64
70
  if (child && !child.killed) return
71
+ if (!mcpConfig) { log('WARNING: no MCP config — the agent can react to events but has no tools to act. Re-connect with --mcp-url.') }
65
72
  const base = ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--strict-mcp-config', '--mcp-config', mcpConfig]
66
73
  const args = canCode ? [...base, '--allowedTools', ...CODE_TOOLS, '--disallowedTools', ...DENY_TOOLS] : [...base, '--allowedTools', 'mcp__openvisio-team__*']
67
74
  const c = spawn(claude, args, { cwd: workdir || undefined, stdio: ['pipe', 'pipe', 'inherit'] })
@@ -100,6 +107,72 @@ function loop({ host, key, claude, mcpConfig, workdir }) {
100
107
  })
101
108
  }
102
109
 
110
+ return { runCycle, canCode }
111
+ }
112
+
113
+ // ── the backend WS loop ──────────────────────────────────────────────────────
114
+ // Real-time: the backend pushes task:assigned / agent:mention over the WS; each
115
+ // pushes ONE Claude cycle. Serialized (one cycle at a time) — events arriving
116
+ // while busy are coalesced into a single follow-up cycle so a burst of mentions
117
+ // doesn't stack up N sessions.
118
+ function loopBackendWs({ wsUrl, apiKey, identifier, claude, mcpConfig, workdir }) {
119
+ const log = (m) => process.stdout.write('[ws ' + new Date().toISOString() + '] ' + m + '\n')
120
+ const { runCycle, canCode } = createCycleRunner({ claude, mcpConfig, workdir, log })
121
+ const fullPrompt = canCode ? CODE_FULL : CYCLE
122
+ const fastPrompt = canCode ? CODE_FAST : CYCLE_FAST
123
+
124
+ let busy = false
125
+ let queued = null // 'full' | 'fast' — a cycle requested while one was running
126
+
127
+ async function drain(kind) {
128
+ if (busy) { queued = (queued === 'full' || kind === 'full') ? 'full' : 'fast'; return }
129
+ busy = true
130
+ try {
131
+ await runCycle(kind === 'full' ? fullPrompt : fastPrompt)
132
+ } finally {
133
+ busy = false
134
+ if (queued) { const next = queued; queued = null; void drain(next) }
135
+ }
136
+ }
137
+
138
+ function onEvent(k, d) {
139
+ if (k === 'task:assigned') {
140
+ const t = d && d.task
141
+ log('task:assigned ' + (t ? '#' + t.id + ' “' + (t.title || '') + '”' : ''))
142
+ void drain('full')
143
+ } else if (k === 'agent:mention') {
144
+ log('agent:mention in channel ' + (d && d.channel_id != null ? d.channel_id : '?'))
145
+ void drain('fast')
146
+ } else {
147
+ log('event ' + k)
148
+ }
149
+ }
150
+
151
+ log('up — backend WS watcher on ' + wsUrl + (canCode ? ' [code: ' + workdir + ']' : ''))
152
+ const handle = connectAgentWs({ wsUrl, apiKey, identifier, onEvent, log })
153
+
154
+ return new Promise(() => {
155
+ // Run until killed. Tidy up the socket on termination so a restarting
156
+ // service doesn't leak a half-open connection.
157
+ const bye = () => { try { handle.close() } catch { /* noop */ } process.exit(0) }
158
+ process.on('SIGTERM', bye)
159
+ process.on('SIGINT', bye)
160
+ })
161
+ }
162
+
163
+ // ── the warm loop ────────────────────────────────────────────────────────────
164
+ function loop({ host, key, claude, mcpConfig, workdir }) {
165
+ const log = (m) => process.stdout.write('[warm ' + new Date().toISOString() + '] ' + m + '\n')
166
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
167
+ const { runCycle, canCode } = createCycleRunner({ claude, mcpConfig, workdir, log })
168
+ const fullPrompt = canCode ? CODE_FULL : CYCLE
169
+ const fastPrompt = canCode ? CODE_FAST : CYCLE_FAST
170
+
171
+ const seen = new Set()
172
+ let busy = false
173
+ let firstCheck = true
174
+ let lastNewAt = Date.now()
175
+
103
176
  async function check() {
104
177
  const res = await fetch(host + '/api/agent/inbox', { headers: { authorization: 'Bearer ' + key } })
105
178
  if (!res.ok) return { items: [], paused: false }
package/src/ws.mjs ADDED
@@ -0,0 +1,106 @@
1
+ // The backend agent WebSocket — a BYO agent's real-time event channel. Connects
2
+ // to the org's API Gateway WS with the agent's api_key + identifier (NOT a JWT),
3
+ // keeps the connection warm with keepalives, auto-reconnects with backoff, and
4
+ // hands targeted dispatches (task:assigned / agent:mention) to a callback.
5
+ //
6
+ // Uses the built-in global WebSocket (Node >= 21). Zero dependencies — see
7
+ // `assertWebSocket` for the version guard.
8
+ //
9
+ // Docs: agent-websocket.md
10
+ // wss://<api-gw>.execute-api.<region>.amazonaws.com/<stage>/?api_key=…&identifier=…
11
+ // client → { "type": "keepalive" } (refreshes last_seen_at)
12
+ // server → { "k": "<kind>", "d": <payload> }
13
+
14
+ import { stripSlash } from './lib.mjs'
15
+
16
+ const KEEPALIVE_MS = 30_000 // refresh last_seen_at well within any idle timeout
17
+ const BACKOFF_MIN_MS = 1_000
18
+ const BACKOFF_MAX_MS = 30_000
19
+
20
+ /** Bail early with a friendly message if the runtime has no global WebSocket. */
21
+ export function assertWebSocket(fail) {
22
+ if (typeof globalThis.WebSocket === 'function') return
23
+ fail(
24
+ 'This Node build has no global WebSocket, which the backend agent channel needs.\n' +
25
+ ' Upgrade to Node >= 21, or run Node 20 with the --experimental-websocket flag.',
26
+ )
27
+ }
28
+
29
+ /**
30
+ * Open a resilient agent WebSocket. Returns a handle with `.close()`.
31
+ *
32
+ * @param {object} o
33
+ * @param {string} o.wsUrl API Gateway base (wss://…/<stage>), no query.
34
+ * @param {string} o.apiKey agent api_key (same as x-agent-api-key).
35
+ * @param {string} o.identifier agent identifier (same as x-agent-identifier).
36
+ * @param {(kind: string, payload: any) => void} o.onEvent targeted dispatch.
37
+ * @param {(msg: string) => void} o.log
38
+ */
39
+ export function connectAgentWs({ wsUrl, apiKey, identifier, onEvent, log }) {
40
+ const base = stripSlash(wsUrl)
41
+ const url = `${base}?api_key=${encodeURIComponent(apiKey)}&identifier=${encodeURIComponent(identifier)}`
42
+
43
+ let ws = null
44
+ let keepalive = null
45
+ let reconnectTimer = null
46
+ let backoff = BACKOFF_MIN_MS
47
+ let closed = false // set once .close() is called — stops reconnects
48
+
49
+ const clearKeepalive = () => { if (keepalive) { clearInterval(keepalive); keepalive = null } }
50
+
51
+ function scheduleReconnect() {
52
+ if (closed || reconnectTimer) return
53
+ const wait = backoff
54
+ backoff = Math.min(backoff * 2, BACKOFF_MAX_MS)
55
+ log(`disconnected — reconnecting in ${Math.round(wait / 1000)}s`)
56
+ reconnectTimer = setTimeout(() => { reconnectTimer = null; open() }, wait)
57
+ }
58
+
59
+ function open() {
60
+ if (closed) return
61
+ let sock
62
+ try { sock = new WebSocket(url) } catch (e) { log('connect failed: ' + (e && e.message ? e.message : e)); scheduleReconnect(); return }
63
+ ws = sock
64
+
65
+ sock.addEventListener('open', () => {
66
+ if (sock !== ws) return
67
+ backoff = BACKOFF_MIN_MS // healthy connection → reset the backoff ramp
68
+ log(`connected as @${identifier}`)
69
+ clearKeepalive()
70
+ keepalive = setInterval(() => {
71
+ try { sock.send(JSON.stringify({ type: 'keepalive' })) } catch { /* closing */ }
72
+ }, KEEPALIVE_MS)
73
+ })
74
+
75
+ sock.addEventListener('message', (ev) => {
76
+ if (sock !== ws) return
77
+ let msg
78
+ try { msg = JSON.parse(typeof ev.data === 'string' ? ev.data : String(ev.data)) } catch { return }
79
+ const kind = msg && msg.k
80
+ if (!kind || kind === 'keepalive:ack') return // keepalive round-trip, nothing to do
81
+ try { onEvent(kind, msg.d) } catch (e) { log('event handler error: ' + (e && e.message ? e.message : e)) }
82
+ })
83
+
84
+ const down = (why) => {
85
+ if (sock !== ws) return
86
+ clearKeepalive()
87
+ ws = null
88
+ log(why)
89
+ scheduleReconnect()
90
+ }
91
+ // $connect 401 (bad/inactive credentials) surfaces as an immediate close.
92
+ sock.addEventListener('close', (e) => down(`socket closed (code ${e && e.code != null ? e.code : '?'})`))
93
+ sock.addEventListener('error', () => down('socket error'))
94
+ }
95
+
96
+ open()
97
+
98
+ return {
99
+ close() {
100
+ closed = true
101
+ clearKeepalive()
102
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null }
103
+ if (ws) { try { ws.close() } catch { /* already gone */ } ws = null }
104
+ },
105
+ }
106
+ }