openvisio-agent 0.11.1 → 0.13.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 +5 -0
- package/bin/cli.mjs +12 -1
- package/package.json +1 -1
- package/src/watch.mjs +142 -11
- package/src/ws.mjs +2 -1
package/README.md
CHANGED
|
@@ -56,18 +56,23 @@ Then `watch --name ada` auto-detects the backend agent and runs a WebSocket loop
|
|
|
56
56
|
|
|
57
57
|
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.
|
|
58
58
|
|
|
59
|
+
Backend/BYO watchers reconcile immediately whenever the process starts or the WebSocket connects. A direct MCP session checks both `poll_inbox` and `get_marching_orders`, covering unanswered mentions, follow-ups, and assigned tasks missed while offline. The same zero-model check runs every five minutes as a safety net; a model starts only when pending work exists.
|
|
60
|
+
|
|
59
61
|
Routine messages, triage, introductions, catch-up checks, and ticket movement use a lightweight model by default. Claude uses Haiku and Codex uses `gpt-5.6-luna`. Requests that clearly require repository work route to the coding model: Sonnet for Claude and `gpt-5.6-sol` for Codex. Override either lane with `--chat-model` and `--model`.
|
|
60
62
|
|
|
61
63
|
```bash
|
|
62
64
|
openvisio-agent watch --name ada # run in this terminal
|
|
63
65
|
openvisio-agent watch --name ada --install # run in the background, start at login
|
|
64
66
|
openvisio-agent watch --name ada --workdir ~/repo # allow REAL work on a git branch
|
|
67
|
+
openvisio-agent stop --name ada # stop service + every ada watcher
|
|
65
68
|
```
|
|
66
69
|
|
|
67
70
|
With `--workdir`, the agent gets file + Bash tools scoped to that repo and works on a branch. Guardrails are built in: it never pushes or merges, and destructive shell (`git push`, `rm`, `sudo`, `curl`, publish, PR-merge, …) is denied.
|
|
68
71
|
|
|
69
72
|
`--install` sets up a background service (launchd on macOS, systemd `--user` on Linux) that runs `watch` and restarts on login. Logs go to `~/.openvisio/<agent>.log` (macOS) or `journalctl --user -u openvisio-<agent>` (Linux).
|
|
70
73
|
|
|
74
|
+
Do not chase auto-changing watcher PIDs. `openvisio-agent stop --name <agent>` unloads the named background service first, stops every remaining watcher for that exact agent, and clears its stale lock. Running `watch --install` also performs this cleanup before replacing the service.
|
|
75
|
+
|
|
71
76
|
## Security
|
|
72
77
|
|
|
73
78
|
- **No opaque script.** You run a named, versioned npm package you can read here and on [npmjs.com](https://www.npmjs.com/package/openvisio-agent).
|
package/bin/cli.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
|
14
14
|
import { fileURLToPath } from 'node:url'
|
|
15
15
|
import { dirname, join } from 'node:path'
|
|
16
16
|
import { parseFlags, slugify, stripSlash, exchangeToken, ensureClaude, ensureCodex, writeJson, mcpConfigPath, configPath, chmodSafe, onPath, OV_DIR, fail, ok, info } from '../src/lib.mjs'
|
|
17
|
-
import { runWatch, installService } from '../src/watch.mjs'
|
|
17
|
+
import { runWatch, installService, stopWatchers } from '../src/watch.mjs'
|
|
18
18
|
|
|
19
19
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
20
20
|
const VERSION = (() => { try { return JSON.parse(readFileSync(join(HERE, '..', 'package.json'), 'utf8')).version } catch { return '0.0.0' } })()
|
|
@@ -27,6 +27,7 @@ Usage:
|
|
|
27
27
|
openvisio-agent connect <ovs_code> --host <url> [--name "<agent>"] [--mcp-url <url>] [--agent claude|codex|opencode]
|
|
28
28
|
openvisio-agent connect --backend <url> --key <api-key> --id <identifier> [--name "<agent>"] [--ws <wss-url>] [--mcp-url <url>] [--agent claude|codex|opencode]
|
|
29
29
|
openvisio-agent watch --name <agent> [--install] [--workspace <dir>] [--chat-only] [--model <m>] [--chat-model <m>] [--debug]
|
|
30
|
+
openvisio-agent stop --name <agent>
|
|
30
31
|
openvisio-agent --help | --version
|
|
31
32
|
|
|
32
33
|
connect
|
|
@@ -72,6 +73,11 @@ watch
|
|
|
72
73
|
setup needed. Use --workspace <dir> to relocate it (e.g. an existing clones folder),
|
|
73
74
|
--chat-only to disable code work, and --install to run in the background on login.
|
|
74
75
|
|
|
76
|
+
stop
|
|
77
|
+
Stops the named agent's launchd/systemd service first, then terminates every
|
|
78
|
+
remaining watcher with that exact --name and clears its stale lock. Use this
|
|
79
|
+
instead of killing changing PIDs: openvisio-agent stop --name Alex
|
|
80
|
+
|
|
75
81
|
Docs: https://www.npmjs.com/package/openvisio-agent`
|
|
76
82
|
|
|
77
83
|
// Register the `openvisio-team` MCP at USER (global) scope so it's available in
|
|
@@ -280,6 +286,11 @@ async function main() {
|
|
|
280
286
|
const rest = parseFlags(argv.slice(1))
|
|
281
287
|
if (cmd === 'connect') return runConnect(rest)
|
|
282
288
|
if (cmd === 'watch') return runWatch(rest)
|
|
289
|
+
if (cmd === 'stop') {
|
|
290
|
+
const name = String(rest.flags.name || rest.positional[0] || '')
|
|
291
|
+
if (!name) fail('Missing agent name.\n Usage: openvisio-agent stop --name <agent>')
|
|
292
|
+
return stopWatchers({ slug: slugify(name) })
|
|
293
|
+
}
|
|
283
294
|
fail(`Unknown command "${cmd}".\n\n${HELP}`)
|
|
284
295
|
}
|
|
285
296
|
|
package/package.json
CHANGED
package/src/watch.mjs
CHANGED
|
@@ -229,18 +229,13 @@ export async function runWatch({ flags }) {
|
|
|
229
229
|
const lock = acquireSingleInstance(slug || 'openvisio')
|
|
230
230
|
if (lock.conflict) {
|
|
231
231
|
const watcherName = slug || 'openvisio'
|
|
232
|
-
const stop = process.platform === 'darwin'
|
|
233
|
-
? `launchctl unload ~/Library/LaunchAgents/io.openvisio.${watcherName}.plist`
|
|
234
|
-
: process.platform === 'win32'
|
|
235
|
-
? 'Stop the existing openvisio-agent process in Task Manager.'
|
|
236
|
-
: `systemctl --user stop openvisio-${watcherName}.service`
|
|
237
232
|
const logs = process.platform === 'darwin'
|
|
238
233
|
? `tail -f ~/.openvisio/${watcherName}.log`
|
|
239
234
|
: `journalctl --user -u openvisio-${watcherName} -f`
|
|
240
235
|
fail(`Another openvisio-agent watcher for "${watcherName}" is already running (pid ${lock.conflict}).\n` +
|
|
241
236
|
` Two watchers for the same agent BOTH reply to every mention — that is what causes duplicate/contradicting messages.\n` +
|
|
242
237
|
` This is usually the auto-restarting background service; killing its PID only makes it restart.\n` +
|
|
243
|
-
`
|
|
238
|
+
` Stop the service and every watcher for this agent with:\n openvisio-agent stop --name ${watcherName}\n` +
|
|
244
239
|
` Or keep the service and inspect its log:\n ${logs}\n` +
|
|
245
240
|
` Refusing to start a second watcher.`)
|
|
246
241
|
}
|
|
@@ -555,6 +550,85 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
555
550
|
// hoping poll_inbox re-surfaces the same item. Accumulated across coalesced
|
|
556
551
|
// events and drained into the next cycle's prompt.
|
|
557
552
|
const pending = []
|
|
553
|
+
let backlogProbeBusy = false
|
|
554
|
+
let lastTaskSignature = ''
|
|
555
|
+
let lastTaskTriggeredAt = 0
|
|
556
|
+
let mcpSessionId = ''
|
|
557
|
+
let mcpRpcId = 0
|
|
558
|
+
|
|
559
|
+
const mcpPayload = async (res) => {
|
|
560
|
+
const body = await res.text()
|
|
561
|
+
const data = body.split(/\r?\n/).filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trim()).pop()
|
|
562
|
+
return JSON.parse(data || body || '{}')
|
|
563
|
+
}
|
|
564
|
+
const mcpPost = async (message, withSession = true) => {
|
|
565
|
+
const res = await fetch(mcpUrl, {
|
|
566
|
+
method: 'POST',
|
|
567
|
+
headers: {
|
|
568
|
+
'content-type': 'application/json',
|
|
569
|
+
accept: 'application/json, text/event-stream',
|
|
570
|
+
'x-agent-api-key': apiKey,
|
|
571
|
+
'x-agent-identifier': identifier,
|
|
572
|
+
...(withSession && mcpSessionId ? { 'mcp-session-id': mcpSessionId } : {}),
|
|
573
|
+
},
|
|
574
|
+
body: JSON.stringify(message),
|
|
575
|
+
})
|
|
576
|
+
return res
|
|
577
|
+
}
|
|
578
|
+
const ensureMcpSession = async () => {
|
|
579
|
+
if (mcpSessionId) return
|
|
580
|
+
const res = await mcpPost({ jsonrpc: '2.0', id: ++mcpRpcId, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'openvisio-agent', version: '0.13.0' } } }, false)
|
|
581
|
+
if (!res.ok) throw new Error('MCP initialize HTTP ' + res.status)
|
|
582
|
+
await mcpPayload(res)
|
|
583
|
+
mcpSessionId = res.headers.get('mcp-session-id') || ''
|
|
584
|
+
if (!mcpSessionId) throw new Error('MCP initialize returned no session id')
|
|
585
|
+
const ready = await mcpPost({ jsonrpc: '2.0', method: 'notifications/initialized' })
|
|
586
|
+
if (!ready.ok) throw new Error('MCP initialized HTTP ' + ready.status)
|
|
587
|
+
}
|
|
588
|
+
const callMcpTool = async (name, args = {}, retried = false) => {
|
|
589
|
+
await ensureMcpSession()
|
|
590
|
+
const res = await mcpPost({ jsonrpc: '2.0', id: ++mcpRpcId, method: 'tools/call', params: { name, arguments: { ...args, agent_api_key: apiKey, agent_identifier: identifier } } })
|
|
591
|
+
if (!res.ok) {
|
|
592
|
+
if (!retried && (res.status === 400 || res.status === 404)) { mcpSessionId = ''; return callMcpTool(name, args, true) }
|
|
593
|
+
throw new Error(`MCP ${name} HTTP ${res.status}`)
|
|
594
|
+
}
|
|
595
|
+
const payload = await mcpPayload(res)
|
|
596
|
+
if (payload.error) throw new Error(`MCP ${name}: ${payload.error.message || 'tool error'}`)
|
|
597
|
+
return JSON.stringify(payload.result ?? payload)
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Reconcile everything that may have arrived while disconnected. This spends no
|
|
601
|
+
// model tokens unless the tools actually report pending work.
|
|
602
|
+
const reconcileBacklog = async () => {
|
|
603
|
+
if (!mcpUrl || backlogProbeBusy || busy) return
|
|
604
|
+
backlogProbeBusy = true
|
|
605
|
+
try {
|
|
606
|
+
const orders = await callMcpTool('get_marching_orders')
|
|
607
|
+
const inbox = await callMcpTool('poll_inbox')
|
|
608
|
+
const normalized = String(orders).replace(/\\"/g, '"').replace(/\s+/g, ' ').trim()
|
|
609
|
+
const empty = !normalized || /\b(?:no|zero)\s+(?:open\s+|assigned\s+|pending\s+)?tasks?\b/i.test(normalized) || /"(?:tasks|assigned|marching_orders)"\s*:\s*\[\s*\]/i.test(normalized)
|
|
610
|
+
if (empty) lastTaskSignature = ''
|
|
611
|
+
else if (/\b(?:task|ticket|title|status|assigned)\b/i.test(normalized)) {
|
|
612
|
+
const signature = normalized.slice(0, 1200)
|
|
613
|
+
const retryDue = Date.now() - lastTaskTriggeredAt > 30 * 60 * 1000
|
|
614
|
+
if (signature !== lastTaskSignature || retryDue) {
|
|
615
|
+
lastTaskSignature = signature
|
|
616
|
+
lastTaskTriggeredAt = Date.now()
|
|
617
|
+
log('backlog reconciliation found pending tasks -> coding cycle')
|
|
618
|
+
void drain('full', 'Backlog reconciliation found one or more tasks in get_marching_orders. Call get_marching_orders now, select every open task assigned to YOU, and execute the oldest/highest-priority one immediately. Do not merely acknowledge it. Move it to the active column, complete the repository work, verify it, open the PR, move the ticket to done when appropriate, and report evidence. Ignore tasks assigned to anyone else.')
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
const inboxText = String(inbox).replace(/\\"/g, '"').replace(/\s+/g, ' ').trim()
|
|
622
|
+
const hasInbox = /"(?:mentions|messages|follow_?ups|items)"\s*:\s*\[\s*\{/i.test(inboxText) || /\b(?:pending|unanswered|new)\s+(?:mention|message|follow.?up)/i.test(inboxText)
|
|
623
|
+
if (hasInbox) {
|
|
624
|
+
log('backlog reconciliation found inbox activity -> reply cycle')
|
|
625
|
+
void drain('fast', 'Backlog reconciliation found pending inbox activity. Call poll_inbox now and handle every unanswered mention or follow-up directed to YOU. Do not respond to other agents\' messages and do not duplicate a reply already present.')
|
|
626
|
+
}
|
|
627
|
+
} catch (e) {
|
|
628
|
+
mcpSessionId = ''
|
|
629
|
+
log('backlog reconciliation failed: ' + (e && e.message ? e.message : e))
|
|
630
|
+
} finally { backlogProbeBusy = false }
|
|
631
|
+
}
|
|
558
632
|
|
|
559
633
|
// Higher rank wins when coalescing cycles requested while one is running.
|
|
560
634
|
const RANK = { fast: 0, intro: 1, coord: 2, sweep: 2, full: 3 }
|
|
@@ -644,12 +718,13 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
644
718
|
// fanned out org-wide. Catch both, keep only agent-assigned tasks, and let the
|
|
645
719
|
// cycle confirm ownership via get_marching_orders before acting.
|
|
646
720
|
if (k === 'task:created' || k === 'task:updated') {
|
|
647
|
-
const
|
|
648
|
-
const
|
|
721
|
+
const envelope = raw.data && typeof raw.data === 'object' ? raw.data : raw
|
|
722
|
+
const t = envelope.task && typeof envelope.task === 'object' ? envelope.task : envelope
|
|
723
|
+
const ag = (t.agent && typeof t.agent === 'object') ? t.agent : (t.assigned_agent && typeof t.assigned_agent === 'object') ? t.assigned_agent : null
|
|
724
|
+
const agentId = t.agent_id ?? t.agentId ?? t.assigned_agent_id ?? t.assignedAgentId ?? t.assignee_agent_id ?? t.assigneeAgentId ?? ag?.id ?? ag?.agent_id ?? null
|
|
649
725
|
if (agentId == null) return // not assigned to an agent — ignore
|
|
650
726
|
// If the payload carries the agent's identifier, filter precisely to US and skip
|
|
651
727
|
// other agents' tasks entirely; otherwise let get_marching_orders confirm.
|
|
652
|
-
const ag = (t.agent && typeof t.agent === 'object') ? t.agent : null
|
|
653
728
|
const agIdent = ag && (ag.identifier || ag.slug) ? String(ag.identifier || ag.slug) : null
|
|
654
729
|
if (agIdent != null && agIdent !== identifier) return
|
|
655
730
|
const key = `${t.id}:${agentId}`
|
|
@@ -724,10 +799,10 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
724
799
|
// so anything assigned while the agent was offline — tasks especially — is still
|
|
725
800
|
// picked up even though we don't hot-poll.
|
|
726
801
|
log('up — backend WS watcher on ' + wsUrl + (canCode ? ' [code: ' + workdir + ']' : ''))
|
|
727
|
-
handle = connectAgentWs({ wsUrl, apiKey, identifier, onEvent, log })
|
|
802
|
+
handle = connectAgentWs({ wsUrl, apiKey, identifier, onEvent, onConnect: () => { setTimeout(() => void reconcileBacklog(), 250) }, log })
|
|
728
803
|
|
|
729
804
|
const DAY_MS = 24 * 60 * 60 * 1000
|
|
730
|
-
let introTimer = null, sweepStartTimer = null, sweepTimer = null
|
|
805
|
+
let introTimer = null, sweepStartTimer = null, sweepTimer = null, taskProbeStartTimer = null, taskProbeTimer = null
|
|
731
806
|
if (mcpConfig || mcpUrl) {
|
|
732
807
|
// Workspace ethics: a one-time hello the FIRST time this agent ever connects.
|
|
733
808
|
const introMarker = join(OV_DIR, 'intro-' + slugify(identifier) + '.done')
|
|
@@ -745,6 +820,10 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
745
820
|
log('startup catch-up sweep skipped (ran within the last 6h)')
|
|
746
821
|
}
|
|
747
822
|
sweepTimer = setInterval(() => { log('daily catch-up sweep'); void drain('sweep', SWEEP) }, DAY_MS)
|
|
823
|
+
// Cheap reliability net: no model runs unless the MCP result actually contains
|
|
824
|
+
// assigned work. This also catches assignments created while the socket was down.
|
|
825
|
+
taskProbeStartTimer = setTimeout(() => void reconcileBacklog(), 3_000)
|
|
826
|
+
taskProbeTimer = setInterval(() => void reconcileBacklog(), 5 * 60 * 1000)
|
|
748
827
|
} else {
|
|
749
828
|
log('no MCP config — skipping intro + daily sweep (agent has no tools to post/act)')
|
|
750
829
|
}
|
|
@@ -756,6 +835,8 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
756
835
|
if (introTimer) clearTimeout(introTimer)
|
|
757
836
|
if (sweepStartTimer) clearTimeout(sweepStartTimer)
|
|
758
837
|
if (sweepTimer) clearInterval(sweepTimer)
|
|
838
|
+
if (taskProbeStartTimer) clearTimeout(taskProbeStartTimer)
|
|
839
|
+
if (taskProbeTimer) clearInterval(taskProbeTimer)
|
|
759
840
|
try { handle && handle.close() } catch { /* noop */ }
|
|
760
841
|
process.exit(0)
|
|
761
842
|
}
|
|
@@ -871,7 +952,57 @@ function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, mode
|
|
|
871
952
|
}
|
|
872
953
|
|
|
873
954
|
// ── background service install (launchd / systemd) ───────────────────────────
|
|
955
|
+
/** Stop the auto-restarting service first, then every orphaned watcher whose
|
|
956
|
+
* --name resolves to this exact slug. Never use a broad pkill pattern. */
|
|
957
|
+
export function stopWatchers({ slug, quiet = false }) {
|
|
958
|
+
const key = slugify(slug || 'openvisio')
|
|
959
|
+
let serviceStopped = false
|
|
960
|
+
|
|
961
|
+
if (process.platform === 'darwin') {
|
|
962
|
+
const plist = join(homedir(), 'Library', 'LaunchAgents', `io.openvisio.${key}.plist`)
|
|
963
|
+
if (existsSync(plist)) {
|
|
964
|
+
const r = spawnSync('launchctl', ['unload', plist], { stdio: 'ignore' })
|
|
965
|
+
serviceStopped = r.status === 0
|
|
966
|
+
}
|
|
967
|
+
} else if (process.platform !== 'win32') {
|
|
968
|
+
const r = spawnSync('systemctl', ['--user', 'stop', `openvisio-${key}.service`], { stdio: 'ignore' })
|
|
969
|
+
serviceStopped = r.status === 0
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
const watcherPids = () => {
|
|
973
|
+
if (process.platform === 'win32') return []
|
|
974
|
+
const r = spawnSync('ps', ['-axo', 'pid=,command='], { encoding: 'utf8' })
|
|
975
|
+
if (r.status !== 0) return []
|
|
976
|
+
const out = []
|
|
977
|
+
for (const line of String(r.stdout || '').split(/\r?\n/)) {
|
|
978
|
+
const m = /^\s*(\d+)\s+(.+)$/.exec(line)
|
|
979
|
+
if (!m) continue
|
|
980
|
+
const pid = Number(m[1]); const command = m[2]
|
|
981
|
+
if (pid === process.pid || !/\bopenvisio-agent\b/.test(command) || !/\bwatch\b/.test(command)) continue
|
|
982
|
+
const name = /(?:^|\s)--name(?:=|\s+)["']?([^"'\s]+)/.exec(command)?.[1]
|
|
983
|
+
if (name && slugify(name) === key) out.push(pid)
|
|
984
|
+
}
|
|
985
|
+
return out
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
const found = watcherPids()
|
|
989
|
+
for (const pid of found) { try { process.kill(pid, 'SIGTERM') } catch { /* already stopped */ } }
|
|
990
|
+
if (found.length) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 600)
|
|
991
|
+
const stubborn = watcherPids()
|
|
992
|
+
for (const pid of stubborn) { try { process.kill(pid, 'SIGKILL') } catch { /* already stopped */ } }
|
|
993
|
+
if (stubborn.length) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 150)
|
|
994
|
+
|
|
995
|
+
const remaining = watcherPids()
|
|
996
|
+
if (remaining.length) fail(`Could not stop watcher${remaining.length === 1 ? '' : 's'} for "${key}": ${remaining.join(', ')}`)
|
|
997
|
+
try { unlinkSync(join(OV_DIR, `watch-${key}.lock`)) } catch { /* absent */ }
|
|
998
|
+
if (!quiet) ok(`Stopped ${found.length} watcher process${found.length === 1 ? '' : 'es'} for "${key}"${serviceStopped ? ' and disabled its background service' : ''}.`)
|
|
999
|
+
return { stopped: found.length, serviceStopped }
|
|
1000
|
+
}
|
|
1001
|
+
|
|
874
1002
|
export function installService({ slug, workdir }) {
|
|
1003
|
+
// Replacing a service must also remove manually-started watchers. Otherwise the
|
|
1004
|
+
// new KeepAlive process repeatedly spawns, sees their lock, exits, and respawns.
|
|
1005
|
+
stopWatchers({ slug, quiet: true })
|
|
875
1006
|
const binPath = onPath('openvisio-agent')
|
|
876
1007
|
if (!binPath || binPath.includes('/_npx/')) {
|
|
877
1008
|
info('Installing openvisio-agent globally so the background service has a stable path…')
|
package/src/ws.mjs
CHANGED
|
@@ -36,7 +36,7 @@ export function assertWebSocket(fail) {
|
|
|
36
36
|
* @param {(kind: string, payload: any) => void} o.onEvent targeted dispatch.
|
|
37
37
|
* @param {(msg: string) => void} o.log
|
|
38
38
|
*/
|
|
39
|
-
export function connectAgentWs({ wsUrl, apiKey, identifier, onEvent, log }) {
|
|
39
|
+
export function connectAgentWs({ wsUrl, apiKey, identifier, onEvent, onConnect, log }) {
|
|
40
40
|
const base = stripSlash(wsUrl)
|
|
41
41
|
const url = `${base}?api_key=${encodeURIComponent(apiKey)}&identifier=${encodeURIComponent(identifier)}`
|
|
42
42
|
|
|
@@ -66,6 +66,7 @@ export function connectAgentWs({ wsUrl, apiKey, identifier, onEvent, log }) {
|
|
|
66
66
|
if (sock !== ws) return
|
|
67
67
|
backoff = BACKOFF_MIN_MS // healthy connection → reset the backoff ramp
|
|
68
68
|
log(`connected as @${identifier}`)
|
|
69
|
+
try { onConnect && onConnect() } catch { /* reconciliation is best-effort */ }
|
|
69
70
|
clearKeepalive()
|
|
70
71
|
keepalive = setInterval(() => {
|
|
71
72
|
try { sock.send(JSON.stringify({ type: 'keepalive' })) } catch { /* closing */ }
|