openvisio-agent 0.4.0 → 0.6.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/bin/cli.mjs +12 -3
- package/package.json +1 -1
- package/src/watch.mjs +225 -42
package/bin/cli.mjs
CHANGED
|
@@ -26,7 +26,7 @@ Connect your coding agent to an OpenVisio team.
|
|
|
26
26
|
Usage:
|
|
27
27
|
openvisio-agent connect <ovs_code> --host <url> [--name "<agent>"] [--mcp-url <url>]
|
|
28
28
|
openvisio-agent connect --backend <url> --key <api-key> --id <identifier> [--name "<agent>"] [--ws <wss-url>] [--mcp-url <url>]
|
|
29
|
-
openvisio-agent watch --name <agent> [--install] [--workspace <dir>] [--chat-only] [--debug]
|
|
29
|
+
openvisio-agent watch --name <agent> [--install] [--workspace <dir>] [--chat-only] [--model <m>] [--chat-model <m>] [--debug]
|
|
30
30
|
openvisio-agent --help | --version
|
|
31
31
|
|
|
32
32
|
connect
|
|
@@ -51,6 +51,13 @@ connect --backend
|
|
|
51
51
|
~/openvisio-workspace; point it at an existing clones folder to
|
|
52
52
|
reuse those. (--workdir is an accepted alias.)
|
|
53
53
|
--chat-only disable code work — chat/ticket tools only.
|
|
54
|
+
--model <m> the model the agent runs on (opus | sonnet | haiku | a full
|
|
55
|
+
claude-… id). Defaults to sonnet — cost-effective, so the agent
|
|
56
|
+
doesn't burn Opus tokens on routine chat. Engineers can also
|
|
57
|
+
change it live from chat: "@agent /model sonnet".
|
|
58
|
+
--chat-model <m> run the lighter chat/mention cycles on an even cheaper model
|
|
59
|
+
while code work stays on --model (e.g. --model sonnet
|
|
60
|
+
--chat-model haiku).
|
|
54
61
|
--no-service skip the background service — just save config + print the
|
|
55
62
|
watch commands to run yourself.
|
|
56
63
|
|
|
@@ -110,7 +117,7 @@ async function runConnect({ positional, flags }) {
|
|
|
110
117
|
const mcpCfg = mcpConfigPath(slug)
|
|
111
118
|
writeJson(mcpCfg, { mcpServers: { 'openvisio-team': { type: 'http', url: mcpUrl, headers: { Authorization: `Bearer ${key}` } } } }, true)
|
|
112
119
|
const wsWorkdir = flags.workdir === true ? process.cwd() : flags.workdir ? String(flags.workdir) : flags.workspace ? String(flags.workspace) : ''
|
|
113
|
-
writeJson(configPath(slug), { host: stripSlash(host), key, mcpUrl, name, slug, mcpConfig: mcpCfg, ...(wsWorkdir ? { workspace: wsWorkdir } : {}), ...(flags['chat-only'] ? { chatOnly: true } : {}) }, true)
|
|
120
|
+
writeJson(configPath(slug), { host: stripSlash(host), key, mcpUrl, name, slug, mcpConfig: mcpCfg, ...(wsWorkdir ? { workspace: wsWorkdir } : {}), ...(flags['chat-only'] ? { chatOnly: true } : {}), ...(flags.model ? { model: String(flags.model) } : {}), ...(flags['chat-model'] ? { chatModel: String(flags['chat-model']) } : {}) }, true)
|
|
114
121
|
|
|
115
122
|
ok(`Connected "${name}" to ${host}.`)
|
|
116
123
|
info()
|
|
@@ -122,6 +129,8 @@ async function runConnect({ positional, flags }) {
|
|
|
122
129
|
info(` openvisio-agent watch --name ${slug} --install # background, auto-start on login`)
|
|
123
130
|
info(` openvisio-agent watch --name ${slug} --workspace <dir> # reuse an existing clones folder`)
|
|
124
131
|
info(` openvisio-agent watch --name ${slug} --chat-only # disable code work (chat/tickets only)`)
|
|
132
|
+
info(` openvisio-agent watch --name ${slug} --model sonnet # cheaper model (default); or opus/haiku`)
|
|
133
|
+
info(' (or change it live from chat: "@agent /model sonnet")')
|
|
125
134
|
}
|
|
126
135
|
|
|
127
136
|
// Backend mode — for agents created against the OpenVisio ORG BACKEND
|
|
@@ -176,7 +185,7 @@ async function runConnectBackend({ flags }) {
|
|
|
176
185
|
writeJson(mcpConfig, { mcpServers: { 'openvisio-team': { type: 'http', url: mcpUrl, headers: { 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier } } } }, true)
|
|
177
186
|
}
|
|
178
187
|
|
|
179
|
-
writeJson(configPath(slug), { mode: 'backend', backend, apiKey, identifier, name, slug, wsUrl, mcpUrl, mcpConfig, ...(workdir ? { workspace: workdir } : {}), ...(chatOnly ? { chatOnly: true } : {}) }, true)
|
|
188
|
+
writeJson(configPath(slug), { mode: 'backend', backend, apiKey, identifier, name, slug, wsUrl, mcpUrl, mcpConfig, ...(workdir ? { workspace: workdir } : {}), ...(chatOnly ? { chatOnly: true } : {}), ...(flags.model ? { model: String(flags.model) } : {}), ...(flags['chat-model'] ? { chatModel: String(flags['chat-model']) } : {}) }, true)
|
|
180
189
|
// A sourceable env file, matching the setup snippet OpenVisio shows.
|
|
181
190
|
const envPath = join(OV_DIR, `${slug}.env`)
|
|
182
191
|
mkdirSync(OV_DIR, { recursive: true })
|
package/package.json
CHANGED
package/src/watch.mjs
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
// than written to disk from a pasted heredoc.
|
|
6
6
|
|
|
7
7
|
import { spawn, spawnSync } from 'node:child_process'
|
|
8
|
-
import { writeFileSync, mkdirSync } from 'node:fs'
|
|
8
|
+
import { writeFileSync, mkdirSync, existsSync } from 'node:fs'
|
|
9
9
|
import { homedir } from 'node:os'
|
|
10
10
|
import { join, dirname } from 'node:path'
|
|
11
|
-
import { OV_DIR, DEFAULT_WORKSPACE, readConfig, onPath, fail, ok, info, slugify, stripSlash, chmodSafe } from './lib.mjs'
|
|
11
|
+
import { OV_DIR, DEFAULT_WORKSPACE, readConfig, writeJson, configPath, onPath, fail, ok, info, slugify, stripSlash, chmodSafe } from './lib.mjs'
|
|
12
12
|
import { connectAgentWs, assertWebSocket } from './ws.mjs'
|
|
13
13
|
|
|
14
14
|
// Behaviour prompts. The openvisio-team MCP bridge requires the agent's
|
|
@@ -20,14 +20,27 @@ import { connectAgentWs, assertWebSocket } from './ws.mjs'
|
|
|
20
20
|
// PROMISES work then stops, forcing the human to remind it to circle back. The
|
|
21
21
|
// CHARTER blocks below assert the toolbox and mandate closing the loop in-cycle.
|
|
22
22
|
|
|
23
|
+
// Applies to EVERY cycle (chat + code). This is what keeps an agent from becoming
|
|
24
|
+
// noise — the failure modes seen in the wild: acknowledging the same task 3 times,
|
|
25
|
+
// answering a question meant for a DIFFERENT agent, and claiming something works
|
|
26
|
+
// then walking it back.
|
|
27
|
+
const REPLY_DISCIPLINE = [
|
|
28
|
+
'REPLY DISCIPLINE — read the recent messages FIRST, then decide whether to speak at all:',
|
|
29
|
+
' • IS IT FOR YOU? Act ONLY on messages addressed to YOU — an @mention of your exact name, a direct question to you, or a reply to something YOU said or did. If a DIFFERENT agent or person was @mentioned or asked to do something, STAY OUT: do not answer for them and do not pick up their task. When it is not yours, posting nothing is the correct move.',
|
|
30
|
+
' • NO DUPLICATES. Before you post, scan the recent thread/channel for what YOU already said. If you already replied to or acknowledged this exact request, do NOT post again. One acknowledgement per task; one answer per question. While a task is in progress, post again ONLY when you have something genuinely NEW (a result, a link, a real blocker) — never re-post "on it".',
|
|
31
|
+
' • BE SURE BEFORE YOU SPEAK. Do not claim something is possible, done, or broken until you have actually verified it — call the tool, read the code, check the real state. Never assert then contradict yourself. If you are unsure, verify FIRST, then give ONE clear, final answer instead of thinking out loud across several messages.',
|
|
32
|
+
].join('\n')
|
|
33
|
+
|
|
23
34
|
// ── CHAT-ONLY agents (no --workdir): chat/ticket tools, no code surface. ──────
|
|
24
35
|
const CHAT_CHARTER = [
|
|
25
36
|
'YOU ARE a connected agent in an OpenVisio team, running in CHAT-ONLY mode. Your tools are the openvisio-team chat/ticket tools (mcp__openvisio-team__*): post_message, poll_inbox, comment_ticket, react_message. You have NO file/Bash/git tools in this mode, so you cannot write code yourself.',
|
|
26
37
|
'WORK ETHIC — behave like a dependable teammate: never leave a promise dangling. Either ACT now (reply, or file a ticket) or say plainly you can\'t and offer to file a ticket / tag a coding agent who can. Never invent progress. Close the loop every cycle — the human should never have to remind you to circle back.',
|
|
38
|
+
'',
|
|
39
|
+
REPLY_DISCIPLINE,
|
|
27
40
|
].join('\n')
|
|
28
41
|
|
|
29
42
|
const CYCLE = CHAT_CHARTER + '\n\nRun one OpenVisio autonomy cycle: call poll_inbox and handle mentions + follow-ups. Reply in 1-3 sentences, @mention people by their EXACT full name, at most one reply per channel. Then stop.'
|
|
30
|
-
const CYCLE_FAST = CHAT_CHARTER + '\n\nNew chat activity. If a specific mention is given above,
|
|
43
|
+
const CYCLE_FAST = CHAT_CHARTER + '\n\nNew chat activity. If a specific mention FOR YOU is given above, reply to it with mcp__openvisio-team__post_message (once). Then call poll_inbox and look at .followUps — but reply ONLY to the ones actually directed at YOU (a question to you, or a reply to your own message); SKIP thread chatter aimed at someone else or another agent. Ignore .tasks/.claimable. AT MOST ONE reply per channel; answer several nudges together in ONE post; never repeat a reply you already sent. If asked for work you have no tool for, say so plainly and offer to file a ticket. Never invent progress. Reply in 1-3 sentences, no summary. Then stop.'
|
|
31
44
|
|
|
32
45
|
// ── CODE agents (--workdir given): full file + Bash + git/gh surface. ─────────
|
|
33
46
|
// A stable "who you are / how you work" charter prepended to every code cycle.
|
|
@@ -44,10 +57,13 @@ const CODE_CHARTER = [
|
|
|
44
57
|
' 2. FINISH, then REPORT. Your LAST action every cycle is a status back to the requester: comment_ticket with what you did (branch + PR link + test result) AND a short channel reply that @mentions the person who asked, by their EXACT full name (a mention only links on an exact full-name match).',
|
|
45
58
|
' 3. Be honest and specific. Never invent progress. If you are genuinely blocked (missing repo, unclear spec, a failing tool), say exactly what you need in one message — that IS closing the loop.',
|
|
46
59
|
' 4. One reply per channel per cycle; answer several nudges together.',
|
|
60
|
+
'',
|
|
61
|
+
REPLY_DISCIPLINE,
|
|
47
62
|
].join('\n')
|
|
48
63
|
|
|
49
64
|
const CODE_FULL = CODE_CHARTER + '\n\n' + [
|
|
50
65
|
'THIS CYCLE: call get_marching_orders and poll_inbox to see assigned tickets + mentions, then act on them.',
|
|
66
|
+
'ACKNOWLEDGE ONCE: for a task assigned to you that you have NOT already acknowledged, post a SINGLE one-line comment_ticket ("On it — picking this up now") before you start. First check the ticket/thread — if you already acknowledged it on an earlier cycle, skip this and just keep working. Then report only when you have the result.',
|
|
51
67
|
'For real code work (an assigned ticket, or a mention asking for changes), run the full flow end-to-end:',
|
|
52
68
|
' 1. GET THE CODE: locate the target repo under your workspace root. If it\'s already a subfolder, cd in and `git pull`; if it isn\'t cloned yet, clone it into the workspace (gh repo clone <org>/<repo>, or git clone <url>) and cd in. Do this yourself — never ask the user for a path.',
|
|
53
69
|
' 2. BRANCH: git checkout -B agent/<short-task-slug>. NEVER work on, commit to, or push main/master.',
|
|
@@ -59,12 +75,29 @@ const CODE_FULL = CODE_CHARTER + '\n\n' + [
|
|
|
59
75
|
].join('\n')
|
|
60
76
|
|
|
61
77
|
const CODE_FAST = CODE_CHARTER + '\n\n' + [
|
|
62
|
-
'New chat activity. If a specific mention is given above,
|
|
63
|
-
'Then poll_inbox and handle .followUps
|
|
64
|
-
'If a message asks for real CODE work, do the WHOLE job now — branch (checkout -B agent/<slug>), edit, run tests, commit, push your branch, open a PR (gh pr create), then reply with the PR link and @mention the requester by exact full name. Never push to main, never --force, never merge.',
|
|
78
|
+
'New chat activity. If a specific mention FOR YOU is given above, reply to it FIRST with mcp__openvisio-team__post_message (once) — before any Bash.',
|
|
79
|
+
'Then poll_inbox and handle .followUps ONLY when the reply is directed at YOU (asks you something, or responds to your own message) — SKIP thread chatter aimed at someone else / another agent. At most one reply per channel, and never repeat a reply you already sent.',
|
|
80
|
+
'If a message asks YOU for real CODE work, do the WHOLE job now — branch (checkout -B agent/<slug>), edit, run tests, commit, push your branch, open a PR (gh pr create), then reply with the PR link and @mention the requester by exact full name. Never push to main, never --force, never merge.',
|
|
65
81
|
'Do NOT promise and stop — finish and report in THIS cycle. Reply 1-3 sentences, no summary. Then stop.',
|
|
66
82
|
].join('\n')
|
|
67
83
|
|
|
84
|
+
// ── Workspace-ethics cycles (both chat-only + code agents) ───────────────────
|
|
85
|
+
// INTRO: a one-time hello when the agent first joins a workspace. SWEEP: a daily
|
|
86
|
+
// (and on-startup) catch-up so nothing assigned while the agent was offline is
|
|
87
|
+
// missed — TASKS especially.
|
|
88
|
+
const INTRO = [
|
|
89
|
+
'You have just JOINED this OpenVisio workspace (your first connection). Workspace etiquette: introduce yourself so the team knows you are here and reachable.',
|
|
90
|
+
'Find the most general channel — call poll_inbox (or list channels) and pick the "general"/main one — then post_message there ONCE: give your name, say you are an AI teammate who picks up tasks assigned to you and answers @mentions, and invite people to mention you. 1-2 sentences, warm and professional.',
|
|
91
|
+
'Post it EXACTLY ONCE, then stop. Do NOT do any other work this cycle.',
|
|
92
|
+
].join('\n')
|
|
93
|
+
const SWEEP = [
|
|
94
|
+
'DAILY CATCH-UP — you may have missed items while offline. Prioritize TASKS.',
|
|
95
|
+
'Call get_marching_orders AND poll_inbox, then:',
|
|
96
|
+
' 1. For every task assigned to YOU that you have NOT started or acknowledged: acknowledge once (comment_ticket "Catching up — picking this up now"), then do the work end-to-end and report (branch/PR + a short channel note). Skip tasks assigned to other agents.',
|
|
97
|
+
' 2. Answer only the @mentions / follow-ups that were directed at YOU and that you have not already answered — at most one reply per channel. Do not reply to threads aimed at someone else.',
|
|
98
|
+
'If there is genuinely nothing outstanding, STOP silently — do NOT post a "nothing to do" message.',
|
|
99
|
+
].join('\n')
|
|
100
|
+
|
|
68
101
|
// Bash covers git/gh/clone/tests; the deny list is where the guardrails live.
|
|
69
102
|
const CODE_TOOLS = ['Read', 'Grep', 'Glob', 'Edit', 'Write', 'MultiEdit', 'TodoWrite', 'Bash', 'mcp__openvisio-team__*']
|
|
70
103
|
// Push + PR creation ARE allowed (agents raise PRs), but main/master, force-pushes,
|
|
@@ -110,6 +143,14 @@ export async function runWatch({ flags }) {
|
|
|
110
143
|
const workdir = chatOnly ? '' : (explicitWorkdir || (saved && (saved.workspace || saved.workdir)) || DEFAULT_WORKSPACE)
|
|
111
144
|
if (workdir) { try { mkdirSync(workdir, { recursive: true }) } catch { /* best-effort; spawn will surface a real problem */ } }
|
|
112
145
|
|
|
146
|
+
// Model the agent runs on. Default SONNET (cost-effective) rather than whatever
|
|
147
|
+
// `claude` defaults to — the agent shouldn't burn Opus tokens on routine chatter.
|
|
148
|
+
// Optional --chat-model runs the lighter chat/mention cycles on an even cheaper
|
|
149
|
+
// model while code cycles stay on the main one. Both persisted + live-changeable
|
|
150
|
+
// via the in-chat `/model` command (see loopBackendWs).
|
|
151
|
+
const model = String(flags.model || (saved && saved.model) || 'sonnet')
|
|
152
|
+
const chatModel = String(flags['chat-model'] || (saved && saved.chatModel) || '')
|
|
153
|
+
|
|
113
154
|
// Backend agents (connect --backend) drive autonomy over a real-time WS instead
|
|
114
155
|
// of REST-polling the frontend relay. Detected by the saved mode / a --ws flag.
|
|
115
156
|
const backendMode = (saved && saved.mode === 'backend') || !!flags.ws
|
|
@@ -121,7 +162,7 @@ export async function runWatch({ flags }) {
|
|
|
121
162
|
if (!apiKey || !identifier) fail('No saved backend credentials for that agent.\n Run `openvisio-agent connect --backend …` first, or pass --key and --id.')
|
|
122
163
|
assertWebSocket(fail)
|
|
123
164
|
if (flags.install) return installService({ slug: slug || 'openvisio', claude, mcpConfig, workdir })
|
|
124
|
-
return loopBackendWs({ wsUrl, apiKey, identifier, claude, mcpConfig, workdir, debug: !!flags.debug })
|
|
165
|
+
return loopBackendWs({ wsUrl, apiKey, identifier, slug: slug || 'openvisio', claude, mcpConfig, workdir, model, chatModel, debug: !!flags.debug })
|
|
125
166
|
}
|
|
126
167
|
|
|
127
168
|
const host = stripSlash(flags.host || (saved && saved.host) || '')
|
|
@@ -130,16 +171,20 @@ export async function runWatch({ flags }) {
|
|
|
130
171
|
|
|
131
172
|
if (flags.install) return installService({ slug: slug || 'openvisio', claude, mcpConfig, workdir })
|
|
132
173
|
|
|
133
|
-
return loop({ host, key, claude, mcpConfig, workdir, debug: !!flags.debug })
|
|
174
|
+
return loop({ host, key, slug: slug || 'openvisio', claude, mcpConfig, workdir, model, chatModel, debug: !!flags.debug })
|
|
134
175
|
}
|
|
135
176
|
|
|
136
177
|
// ── Claude Code warm-session cycle runner (shared by the REST + WS loops) ─────
|
|
137
178
|
// One persistent stream-json session, poked with a prompt per cycle. Recycled
|
|
138
179
|
// after MAX_TURNS or SESSION_IDLE_MS. Returns { runCycle, canCode }.
|
|
139
|
-
function createCycleRunner({ claude, mcpConfig, workdir, log, debug }) {
|
|
180
|
+
function createCycleRunner({ claude, mcpConfig, workdir, log, debug, model }) {
|
|
140
181
|
const canCode = !!workdir
|
|
141
182
|
const maxCycleMs = canCode ? MAX_CODE_CYCLE_MS : MAX_CYCLE_MS
|
|
142
183
|
let child = null
|
|
184
|
+
// The model the CURRENT session was spawned with. runCycle can pass a different
|
|
185
|
+
// model per cycle (cheap for chat, stronger for code) — a change recycles the
|
|
186
|
+
// session so the new model takes effect.
|
|
187
|
+
let sessionModel = model || null
|
|
143
188
|
let turnsThisSession = 0
|
|
144
189
|
let sessionStartedAt = 0
|
|
145
190
|
let resolveTurn = null
|
|
@@ -165,7 +210,7 @@ function createCycleRunner({ claude, mcpConfig, workdir, log, debug }) {
|
|
|
165
210
|
function ensureSession() {
|
|
166
211
|
if (child && !child.killed) return
|
|
167
212
|
if (!mcpConfig) { log('WARNING: no MCP config — the agent can react to events but has no tools to act. Re-connect with --mcp-url.') }
|
|
168
|
-
const base = ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--strict-mcp-config', '--mcp-config', mcpConfig]
|
|
213
|
+
const base = ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--strict-mcp-config', '--mcp-config', mcpConfig, ...(sessionModel ? ['--model', sessionModel] : [])]
|
|
169
214
|
const args = canCode ? [...base, '--allowedTools', ...CODE_TOOLS, '--disallowedTools', ...DENY_TOOLS] : [...base, '--allowedTools', 'mcp__openvisio-team__*']
|
|
170
215
|
const c = spawn(claude, args, { cwd: workdir || undefined, stdio: ['pipe', 'pipe', 'inherit'] })
|
|
171
216
|
child = c
|
|
@@ -186,13 +231,19 @@ function createCycleRunner({ claude, mcpConfig, workdir, log, debug }) {
|
|
|
186
231
|
})
|
|
187
232
|
c.on('exit', (code) => { if (c !== child) { log('old session exited ' + code); return } log('session exited ' + code); child = null; settleTurn({ type: 'result', subtype: 'exit' }) })
|
|
188
233
|
c.on('error', () => { if (c !== child) return; child = null; settleTurn({ type: 'result', subtype: 'error' }) })
|
|
189
|
-
log('warm session started' + (canCode
|
|
234
|
+
log('warm session started' + (sessionModel ? ' [model ' + sessionModel + ']' : '') + (canCode
|
|
190
235
|
? ' [CODE mode — workspace ' + workdir + ' — finds/clones the org\'s repos here, branches, pushes, opens PRs]'
|
|
191
236
|
: ' [CHAT-ONLY mode (--chat-only) — chat/ticket tools only, no code work]'))
|
|
192
237
|
}
|
|
193
238
|
|
|
194
|
-
function runCycle(prompt) {
|
|
239
|
+
function runCycle(prompt, cycleModel) {
|
|
195
240
|
return new Promise((resolve) => {
|
|
241
|
+
// A per-cycle model override (e.g. chat on a cheaper model than code) — a
|
|
242
|
+
// change means the warm session must be respawned with the new --model.
|
|
243
|
+
if (cycleModel && cycleModel !== sessionModel) {
|
|
244
|
+
if (child) { log('model change ' + (sessionModel || 'default') + ' → ' + cycleModel + ' — recycling session'); try { child.kill() } catch { /* gone */ } child = null }
|
|
245
|
+
sessionModel = cycleModel
|
|
246
|
+
}
|
|
196
247
|
if (child && (turnsThisSession >= MAX_TURNS || Date.now() - sessionStartedAt > SESSION_IDLE_MS)) {
|
|
197
248
|
log('recycling session (turns=' + turnsThisSession + ')')
|
|
198
249
|
try { child.kill() } catch { /* already gone */ }
|
|
@@ -219,19 +270,32 @@ function createCycleRunner({ claude, mcpConfig, workdir, log, debug }) {
|
|
|
219
270
|
}
|
|
220
271
|
|
|
221
272
|
// ── the backend WS loop ──────────────────────────────────────────────────────
|
|
222
|
-
// Real-time: the backend pushes task:
|
|
223
|
-
// pushes ONE Claude cycle. Serialized (one cycle at a time) — events
|
|
224
|
-
// while busy are coalesced into a single follow-up cycle so a burst
|
|
225
|
-
//
|
|
226
|
-
function loopBackendWs({ wsUrl, apiKey, identifier, claude, mcpConfig, workdir, debug }) {
|
|
273
|
+
// Real-time: the backend pushes task:created/task:updated (assignments) + agent:mention
|
|
274
|
+
// over the WS; each pushes ONE Claude cycle. Serialized (one cycle at a time) — events
|
|
275
|
+
// arriving while busy are coalesced into a single follow-up cycle so a burst doesn't
|
|
276
|
+
// stack up N sessions. Plus a one-time intro on first connect and a daily catch-up sweep.
|
|
277
|
+
function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, mcpConfig, workdir, model, chatModel, debug }) {
|
|
227
278
|
const log = (m) => process.stdout.write('[ws ' + new Date().toISOString() + '] ' + m + '\n')
|
|
228
|
-
const { runCycle, canCode } = createCycleRunner({ claude, mcpConfig, workdir, log, debug })
|
|
279
|
+
const { runCycle, canCode } = createCycleRunner({ claude, mcpConfig, workdir, log, debug, model })
|
|
229
280
|
const fullPrompt = canCode ? CODE_FULL : CYCLE
|
|
230
281
|
const fastPrompt = canCode ? CODE_FAST : CYCLE_FAST
|
|
282
|
+
// Live model state — changeable at runtime by the in-chat `/model` command.
|
|
283
|
+
// codeModel drives full/sweep cycles; chatModel (if set) the lighter fast/intro
|
|
284
|
+
// ones, so routine chatter can run cheaper than real code work.
|
|
285
|
+
let codeModel = model
|
|
286
|
+
let liteModel = chatModel || model
|
|
231
287
|
|
|
232
288
|
let busy = false
|
|
233
|
-
let queued = null // 'full' | 'fast' — a cycle requested while one was running
|
|
289
|
+
let queued = null // 'full' | 'fast' | 'sweep' | 'intro' — a cycle requested while one was running
|
|
234
290
|
let handle = null
|
|
291
|
+
// Tasks we've already reacted to (keyed task-id:agent — so a REASSIGNMENT to a
|
|
292
|
+
// different agent re-triggers), so a noisy stream of task:updated events doesn't
|
|
293
|
+
// re-acknowledge the same assignment.
|
|
294
|
+
const seenTasks = new Set()
|
|
295
|
+
// Mentions we've already reacted to (by message id) — the backend can re-deliver
|
|
296
|
+
// an agent:mention (reconnect replay, dup fan-out), which otherwise makes the
|
|
297
|
+
// agent reply to the SAME message twice.
|
|
298
|
+
const seenMentions = new Set()
|
|
235
299
|
// Context lines from the events themselves (the WS payload already carries the
|
|
236
300
|
// channel + message / task), so the agent acts on THEM directly instead of
|
|
237
301
|
// hoping poll_inbox re-surfaces the same item. Accumulated across coalesced
|
|
@@ -243,16 +307,22 @@ function loopBackendWs({ wsUrl, apiKey, identifier, claude, mcpConfig, workdir,
|
|
|
243
307
|
// Hand them to the model up front so it never shells around hunting for them.
|
|
244
308
|
const credNote = `AUTH: the openvisio-team (mcp__openvisio-team__*) tools REQUIRE two arguments on EVERY call — agent_identifier: "${identifier}" and agent_api_key: "${apiKey}". Include BOTH on every openvisio-team tool call (post_message, poll_inbox, react_message, comment_ticket, …). They are given to you right here — do NOT hunt for them (no Bash/grep/cat/find to locate credentials, no reading memory); just call the tools with these exact values. (Bash/git/gh ARE for your code work — this rule is only about not searching for these keys.)`
|
|
245
309
|
|
|
310
|
+
// Higher rank wins when coalescing cycles requested while one is running.
|
|
311
|
+
const RANK = { fast: 0, intro: 1, sweep: 2, full: 3 }
|
|
312
|
+
const baseFor = (kind) => kind === 'intro' ? INTRO : (kind === 'full' || kind === 'sweep') ? fullPrompt : fastPrompt
|
|
313
|
+
|
|
246
314
|
async function drain(kind, context) {
|
|
247
315
|
if (context) pending.push(context)
|
|
248
|
-
if (busy) { queued = (
|
|
316
|
+
if (busy) { queued = (RANK[kind] ?? 0) >= (RANK[queued] ?? 0) ? kind : queued; log('busy — queued a ' + kind + ' follow-up cycle'); return }
|
|
249
317
|
busy = true
|
|
250
318
|
const ctx = pending.splice(0) // take everything accumulated so far
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
319
|
+
const prompt = credNote + '\n\n' + (ctx.length ? ctx.join('\n') + '\n\n' : '') + baseFor(kind)
|
|
320
|
+
// Chat-shaped cycles (mentions/intro) may run on the cheaper chat model; code
|
|
321
|
+
// work (full/sweep) uses the main model.
|
|
322
|
+
const useModel = (kind === 'fast' || kind === 'intro') ? liteModel : codeModel
|
|
323
|
+
log('running ' + kind + ' cycle…' + (ctx.length ? ' (' + ctx.length + ' event' + (ctx.length === 1 ? '' : 's') + ')' : '') + (useModel ? ' [' + useModel + ']' : ''))
|
|
254
324
|
try {
|
|
255
|
-
await runCycle(prompt)
|
|
325
|
+
await runCycle(prompt, useModel)
|
|
256
326
|
} finally {
|
|
257
327
|
busy = false
|
|
258
328
|
if (queued || pending.length) { const next = queued || 'fast'; queued = null; void drain(next) }
|
|
@@ -267,13 +337,51 @@ function loopBackendWs({ wsUrl, apiKey, identifier, claude, mcpConfig, workdir,
|
|
|
267
337
|
return (`${s.first_name || ''} ${s.last_name || ''}`.trim() || s.name || s.email || '')
|
|
268
338
|
}
|
|
269
339
|
|
|
340
|
+
// Engineers change the model under the hood from chat: "/model", "/model sonnet",
|
|
341
|
+
// "use model haiku", "switch model to opus". Returns {report} | {set} | {invalid}.
|
|
342
|
+
const normalizeModel = (s) => {
|
|
343
|
+
const x = String(s || '').toLowerCase().replace(/[.,!?]+$/, '')
|
|
344
|
+
if (x === 'opus' || x === 'sonnet' || x === 'haiku') return x
|
|
345
|
+
return /^claude-[a-z0-9.\-\[\]]+$/i.test(x) ? x : null
|
|
346
|
+
}
|
|
347
|
+
const parseModelCmd = (text) => {
|
|
348
|
+
// Slash form is unambiguous: "/model" (report) or "/model <name>".
|
|
349
|
+
const slash = /(?:^|\s)\/model\b(?:\s+(\S+))?/i.exec(text)
|
|
350
|
+
if (slash) { const a = slash[1]; if (!a) return { report: true }; const n = normalizeModel(a); return n ? { set: n } : { invalid: a } }
|
|
351
|
+
// Natural language: only when a VALID model name is explicitly given after the
|
|
352
|
+
// word "model" — so "use the model file in the repo" does NOT switch anything.
|
|
353
|
+
const nl = /\b(?:set|switch|change|use)\s+(?:the\s+|your\s+)?model\s+(?:to\s+)?(\S+)/i.exec(text)
|
|
354
|
+
if (nl) { const n = normalizeModel(nl[1]); if (n) return { set: n } }
|
|
355
|
+
if (/\b(?:which|what)\s+model\s+(?:are\s+you|do\s+you|you\b)/i.test(text)) return { report: true }
|
|
356
|
+
return null
|
|
357
|
+
}
|
|
358
|
+
// Persist a model change so a restart / the background service keeps it.
|
|
359
|
+
const persistModel = (mdl) => {
|
|
360
|
+
if (!slug) return
|
|
361
|
+
try { writeJson(configPath(slug), { ...(readConfig(slug) || {}), model: mdl, chatModel: mdl }, true) } catch { /* best-effort */ }
|
|
362
|
+
}
|
|
363
|
+
|
|
270
364
|
function onEvent(k, d) {
|
|
271
365
|
const raw = d && typeof d === 'object' ? d : {}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
366
|
+
// The backend has NO `task:assigned` event — a task assigned to an agent arrives
|
|
367
|
+
// as `task:created` (assigned on create) or `task:updated` (assignee changed),
|
|
368
|
+
// fanned out org-wide. Catch both, keep only agent-assigned tasks, and let the
|
|
369
|
+
// cycle confirm ownership via get_marching_orders before acting.
|
|
370
|
+
if (k === 'task:created' || k === 'task:updated') {
|
|
371
|
+
const t = raw.task && typeof raw.task === 'object' ? raw.task : {}
|
|
372
|
+
const agentId = t.agent_id != null ? t.agent_id : (t.agentId != null ? t.agentId : null)
|
|
373
|
+
if (agentId == null) return // not assigned to an agent — ignore
|
|
374
|
+
// If the payload carries the agent's identifier, filter precisely to US and skip
|
|
375
|
+
// other agents' tasks entirely; otherwise let get_marching_orders confirm.
|
|
376
|
+
const ag = (t.agent && typeof t.agent === 'object') ? t.agent : null
|
|
377
|
+
const agIdent = ag && (ag.identifier || ag.slug) ? String(ag.identifier || ag.slug) : null
|
|
378
|
+
if (agIdent != null && agIdent !== identifier) return
|
|
379
|
+
const key = `${t.id}:${agentId}`
|
|
380
|
+
if (t.id != null && seenTasks.has(key)) return
|
|
381
|
+
if (t.id != null) { seenTasks.add(key); if (seenTasks.size > 500) seenTasks.clear() }
|
|
382
|
+
log('task ' + k.slice(5) + ' #' + (t.id != null ? t.id : '?') + ' (agent ' + agentId + ') “' + (t.title || '') + '”')
|
|
275
383
|
const desc = t.description ? ' — ' + String(t.description).replace(/\s+/g, ' ').slice(0, 400) : ''
|
|
276
|
-
void drain('full',
|
|
384
|
+
void drain('full', `A task was just ${k === 'task:created' ? 'created and assigned' : 'assigned'} to an agent in this workspace — task #${t.id != null ? t.id : '?'}: "${t.title || ''}"${desc} (agent_id ${agentId}). Call get_marching_orders to confirm it is assigned to YOU. If it IS yours: FIRST post a brief comment_ticket acknowledgement ("On it — picking this up now, will update shortly"), THEN do the work end-to-end and report back (branch/PR + a channel note). If it is NOT yours, do nothing and stop.`)
|
|
277
385
|
} else if (k === 'agent:mention') {
|
|
278
386
|
const cid = raw.channel_id != null ? raw.channel_id : (raw.channelId != null ? raw.channelId : null)
|
|
279
387
|
const msg = raw.message && typeof raw.message === 'object' ? raw.message : {}
|
|
@@ -284,9 +392,33 @@ function loopBackendWs({ wsUrl, apiKey, identifier, claude, mcpConfig, workdir,
|
|
|
284
392
|
const mid = msg.id != null ? msg.id : (msg.message_id != null ? msg.message_id : null)
|
|
285
393
|
const parent = msg.parent_id != null ? msg.parent_id : (msg.parentId != null ? msg.parentId : null)
|
|
286
394
|
const threadRoot = parent != null ? parent : mid
|
|
395
|
+
// De-dupe: the same mention re-delivered (reconnect replay / dup fan-out) must
|
|
396
|
+
// NOT trigger a second reply to the same message.
|
|
397
|
+
if (mid != null) {
|
|
398
|
+
if (seenMentions.has(mid)) { log('agent:mention (dup ' + mid + ') — skipped'); return }
|
|
399
|
+
seenMentions.add(mid); if (seenMentions.size > 500) seenMentions.clear()
|
|
400
|
+
}
|
|
401
|
+
// Under-the-hood model control from chat (view / switch the model the agent runs).
|
|
402
|
+
const mcmd = cid != null ? parseModelCmd(text) : null
|
|
403
|
+
if (mcmd) {
|
|
404
|
+
const thread = threadRoot != null ? `, parent_id ${threadRoot}` : ''
|
|
405
|
+
if (mcmd.report) {
|
|
406
|
+
log('model query → ' + codeModel + (liteModel !== codeModel ? ' / chat ' + liteModel : ''))
|
|
407
|
+
void drain('fast', `An engineer asked which model you're running. Reply once in channel ${cid}${thread} (with your agent creds): "I'm currently running on ${codeModel}${liteModel !== codeModel ? ` (chat replies on ${liteModel})` : ''}." One line. Then stop.`)
|
|
408
|
+
} else if (mcmd.invalid) {
|
|
409
|
+
void drain('fast', `An engineer tried to switch your model to "${mcmd.invalid}", which isn't one you recognize. Reply once in channel ${cid}${thread} (with your agent creds): say you support "opus", "sonnet", "haiku", or a full "claude-…" id, and ask which they meant. One line. Then stop.`)
|
|
410
|
+
} else {
|
|
411
|
+
const prev = codeModel
|
|
412
|
+
codeModel = mcmd.set; liteModel = mcmd.set
|
|
413
|
+
persistModel(mcmd.set)
|
|
414
|
+
log('model switched ' + prev + ' → ' + codeModel + (who ? ' (by ' + who + ')' : ''))
|
|
415
|
+
void drain('fast', `An engineer switched your underlying model to "${codeModel}" — it is now active for your next actions. Post ONE short confirmation in channel ${cid}${thread} (with your agent creds): e.g. "Switched to ${codeModel} — I'll run on it from here." Then stop.`)
|
|
416
|
+
}
|
|
417
|
+
return
|
|
418
|
+
}
|
|
287
419
|
log('agent:mention in channel ' + (cid != null ? cid : '?') + (threadRoot != null ? ' (thread ' + threadRoot + ')' : ''))
|
|
288
420
|
const ctx = cid != null
|
|
289
|
-
? `You were @mentioned in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}".
|
|
421
|
+
? `You were @mentioned in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". This mention is FOR YOU — reply once with mcp__openvisio-team__post_message as your FIRST action — arguments: channel_id ${cid}${threadRoot != null ? `, parent_id ${threadRoot} (reply IN THAT THREAD, do not start a new top-level message)` : ''}, plus agent_identifier + agent_api_key from the AUTH line above, and a 1-3 sentence reply. FIRST read the recent messages in this thread: if you already answered this, or it turns out another agent was the one addressed, do NOT post. Be sure of your answer before sending — do not reply then correct yourself.${who ? ` To @mention them back, write their EXACT full name "@${who}" (a mention only links when the name matches exactly — "@${who.split(' ')[0]}" alone will NOT).` : ''} You already have the message here — do NOT poll_inbox to find it.`
|
|
290
422
|
: undefined
|
|
291
423
|
void drain('fast', ctx)
|
|
292
424
|
} else if (k === 'error') {
|
|
@@ -297,35 +429,77 @@ function loopBackendWs({ wsUrl, apiKey, identifier, claude, mcpConfig, workdir,
|
|
|
297
429
|
}
|
|
298
430
|
}
|
|
299
431
|
|
|
300
|
-
//
|
|
301
|
-
// A thread reply that @mentions the agent fires agent:mention and
|
|
302
|
-
// in-thread above
|
|
303
|
-
//
|
|
304
|
-
//
|
|
432
|
+
// The WebSocket drives real-time reactions (task:created/updated assigned to us,
|
|
433
|
+
// agent:mention). A thread reply that @mentions the agent fires agent:mention and
|
|
434
|
+
// is handled in-thread above. In addition we run a DAILY (and on-startup) sweep
|
|
435
|
+
// so anything assigned while the agent was offline — tasks especially — is still
|
|
436
|
+
// picked up even though we don't hot-poll.
|
|
305
437
|
log('up — backend WS watcher on ' + wsUrl + (canCode ? ' [code: ' + workdir + ']' : ''))
|
|
306
438
|
handle = connectAgentWs({ wsUrl, apiKey, identifier, onEvent, log })
|
|
307
439
|
|
|
440
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
441
|
+
let introTimer = null, sweepStartTimer = null, sweepTimer = null
|
|
442
|
+
if (mcpConfig) {
|
|
443
|
+
// Workspace ethics: a one-time hello the FIRST time this agent ever connects.
|
|
444
|
+
const introMarker = join(OV_DIR, 'intro-' + slugify(identifier) + '.done')
|
|
445
|
+
if (!existsSync(introMarker)) {
|
|
446
|
+
try { mkdirSync(OV_DIR, { recursive: true }); writeFileSync(introMarker, new Date().toISOString() + '\n') } catch { /* best-effort */ }
|
|
447
|
+
log('first connection — introducing self to the workspace')
|
|
448
|
+
introTimer = setTimeout(() => void drain('intro'), 5000) // let the socket subscribe first
|
|
449
|
+
}
|
|
450
|
+
// Catch-up sweep: shortly after startup (covers downtime) + once every day.
|
|
451
|
+
// Sooner rather than later: a freshly-added agent whose WS subscription isn't
|
|
452
|
+
// live yet still catches pending mentions/tasks via this REST poll_inbox sweep.
|
|
453
|
+
sweepStartTimer = setTimeout(() => { log('startup catch-up sweep'); void drain('sweep', SWEEP) }, 12_000)
|
|
454
|
+
sweepTimer = setInterval(() => { log('daily catch-up sweep'); void drain('sweep', SWEEP) }, DAY_MS)
|
|
455
|
+
} else {
|
|
456
|
+
log('no MCP config — skipping intro + daily sweep (agent has no tools to post/act)')
|
|
457
|
+
}
|
|
458
|
+
|
|
308
459
|
return new Promise(() => {
|
|
309
|
-
// Run until killed. Tidy up the socket on termination so a restarting
|
|
310
|
-
// service doesn't leak a half-open connection.
|
|
311
|
-
const bye = () => {
|
|
460
|
+
// Run until killed. Tidy up the socket + timers on termination so a restarting
|
|
461
|
+
// service doesn't leak a half-open connection or a dangling interval.
|
|
462
|
+
const bye = () => {
|
|
463
|
+
if (introTimer) clearTimeout(introTimer)
|
|
464
|
+
if (sweepStartTimer) clearTimeout(sweepStartTimer)
|
|
465
|
+
if (sweepTimer) clearInterval(sweepTimer)
|
|
466
|
+
try { handle && handle.close() } catch { /* noop */ }
|
|
467
|
+
process.exit(0)
|
|
468
|
+
}
|
|
312
469
|
process.on('SIGTERM', bye)
|
|
313
470
|
process.on('SIGINT', bye)
|
|
314
471
|
})
|
|
315
472
|
}
|
|
316
473
|
|
|
317
474
|
// ── the warm loop ────────────────────────────────────────────────────────────
|
|
318
|
-
function loop({ host, key, claude, mcpConfig, workdir, debug }) {
|
|
475
|
+
function loop({ host, key, slug, claude, mcpConfig, workdir, model, chatModel, debug }) {
|
|
319
476
|
const log = (m) => process.stdout.write('[warm ' + new Date().toISOString() + '] ' + m + '\n')
|
|
320
477
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
321
|
-
const { runCycle, canCode } = createCycleRunner({ claude, mcpConfig, workdir, log, debug })
|
|
478
|
+
const { runCycle, canCode } = createCycleRunner({ claude, mcpConfig, workdir, log, debug, model })
|
|
322
479
|
const fullPrompt = canCode ? CODE_FULL : CYCLE
|
|
323
480
|
const fastPrompt = canCode ? CODE_FAST : CYCLE_FAST
|
|
481
|
+
const liteModel = chatModel || model // cheaper model for chat/quick cycles
|
|
324
482
|
|
|
325
483
|
const seen = new Set()
|
|
326
484
|
let busy = false
|
|
327
485
|
let firstCheck = true
|
|
328
486
|
let lastNewAt = Date.now()
|
|
487
|
+
// A one-off prompt (intro / daily sweep) the main loop runs the next time it's
|
|
488
|
+
// free — keeps everything on the single runCycle so nothing overlaps.
|
|
489
|
+
let queuedSpecial = null
|
|
490
|
+
if (mcpConfig) {
|
|
491
|
+
const introMarker = join(OV_DIR, 'intro-' + (slug || 'openvisio') + '.done')
|
|
492
|
+
if (!existsSync(introMarker)) {
|
|
493
|
+
try { mkdirSync(OV_DIR, { recursive: true }); writeFileSync(introMarker, new Date().toISOString() + '\n') } catch { /* best-effort */ }
|
|
494
|
+
log('first run — introducing self to the workspace')
|
|
495
|
+
setTimeout(() => { queuedSpecial = INTRO }, 5000)
|
|
496
|
+
}
|
|
497
|
+
// The startup poll marks pre-existing tasks as "seen" (so it won't re-handle old
|
|
498
|
+
// ones) — which would also skip tasks assigned while offline. A startup + daily
|
|
499
|
+
// sweep re-checks get_marching_orders so those are still picked up.
|
|
500
|
+
setTimeout(() => { log('startup catch-up sweep'); queuedSpecial = SWEEP + '\n\n' + fullPrompt }, 12_000)
|
|
501
|
+
setInterval(() => { log('daily catch-up sweep'); queuedSpecial = SWEEP + '\n\n' + fullPrompt }, 24 * 60 * 60 * 1000)
|
|
502
|
+
}
|
|
329
503
|
|
|
330
504
|
async function check() {
|
|
331
505
|
const res = await fetch(host + '/api/agent/inbox', { headers: { authorization: 'Bearer ' + key } })
|
|
@@ -345,6 +519,15 @@ function loop({ host, key, claude, mcpConfig, workdir, debug }) {
|
|
|
345
519
|
for (;;) {
|
|
346
520
|
let delay = FAST
|
|
347
521
|
try {
|
|
522
|
+
// Run a queued one-off (intro / daily sweep) first when free, on the same
|
|
523
|
+
// runCycle so it never overlaps a normal cycle.
|
|
524
|
+
if (!busy && queuedSpecial) {
|
|
525
|
+
const p = queuedSpecial; queuedSpecial = null
|
|
526
|
+
busy = true
|
|
527
|
+
try { await runCycle(p, model) } finally { busy = false; lastNewAt = Date.now() }
|
|
528
|
+
await sleep(FAST)
|
|
529
|
+
continue
|
|
530
|
+
}
|
|
348
531
|
const res = busy ? { items: [], paused: false } : await check()
|
|
349
532
|
const items = Array.isArray(res.items) ? res.items : []
|
|
350
533
|
if (firstCheck && Array.isArray(res.items)) {
|
|
@@ -362,14 +545,14 @@ function loop({ host, key, claude, mcpConfig, workdir, debug }) {
|
|
|
362
545
|
const q = await quickReply()
|
|
363
546
|
if (q && q.ok) {
|
|
364
547
|
log(fresh.length + ' new -> quick reply (' + (q.replied || 0) + ' posted)')
|
|
365
|
-
if (q.needsWork && canCode) { log('needs work -> full cycle'); await runCycle(fullPrompt) }
|
|
548
|
+
if (q.needsWork && canCode) { log('needs work -> full cycle'); await runCycle(fullPrompt, model) }
|
|
366
549
|
} else {
|
|
367
550
|
log(fresh.length + ' new -> fast reply (claude fallback)')
|
|
368
|
-
await runCycle(fastPrompt)
|
|
551
|
+
await runCycle(fastPrompt, liteModel)
|
|
369
552
|
}
|
|
370
553
|
} else {
|
|
371
554
|
log(fresh.length + ' new item(s) -> full cycle' + (canCode ? ' [code]' : ''))
|
|
372
|
-
await runCycle(fullPrompt)
|
|
555
|
+
await runCycle(fullPrompt, model)
|
|
373
556
|
}
|
|
374
557
|
for (const i of items) seen.add(i)
|
|
375
558
|
if (seen.size > 500) { seen.clear(); for (const i of items) seen.add(i) }
|