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 +25 -0
- package/bin/cli.mjs +96 -3
- package/package.json +1 -1
- package/src/watch.mjs +89 -16
- package/src/ws.mjs +106 -0
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
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',
|
|
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
|
-
// ──
|
|
44
|
-
|
|
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
|
+
}
|