openvisio-agent 0.5.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.
Files changed (3) hide show
  1. package/bin/cli.mjs +12 -3
  2. package/package.json +1 -1
  3. package/src/watch.mjs +123 -27
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.5.0",
3
+ "version": "0.6.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
@@ -8,7 +8,7 @@ import { spawn, spawnSync } from 'node:child_process'
8
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, post that reply FIRST with mcp__openvisio-team__post_message. Then call poll_inbox and handle any .followUps (thread replies you are part of, even without an @mention) ignore .tasks/.claimable. Post AT MOST ONE reply per channel: if the same person sent several nudges, answer them together in ONE post. 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.'
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,11 +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.',
51
- 'ACKNOWLEDGE FIRST: for a task assigned to you, post a one-line comment_ticket ("On it — picking this up now") BEFORE you start, so the team sees you have it. Then do the work and report when done.',
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.',
52
67
  'For real code work (an assigned ticket, or a mention asking for changes), run the full flow end-to-end:',
53
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.',
54
69
  ' 2. BRANCH: git checkout -B agent/<short-task-slug>. NEVER work on, commit to, or push main/master.',
@@ -60,9 +75,9 @@ const CODE_FULL = CODE_CHARTER + '\n\n' + [
60
75
  ].join('\n')
61
76
 
62
77
  const CODE_FAST = CODE_CHARTER + '\n\n' + [
63
- 'New chat activity. If a specific mention is given above, post that reply FIRST with mcp__openvisio-team__post_message — before any Bash.',
64
- 'Then poll_inbox and handle .followUps (thread replies you are part of, even without an @mention), at most one reply per channel.',
65
- '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.',
66
81
  'Do NOT promise and stop — finish and report in THIS cycle. Reply 1-3 sentences, no summary. Then stop.',
67
82
  ].join('\n')
68
83
 
@@ -78,8 +93,8 @@ const INTRO = [
78
93
  const SWEEP = [
79
94
  'DAILY CATCH-UP — you may have missed items while offline. Prioritize TASKS.',
80
95
  'Call get_marching_orders AND poll_inbox, then:',
81
- ' 1. For every task assigned to you that you have NOT started: post a brief comment_ticket acknowledgement first ("Catching up — picking this up now"), then do the work end-to-end and report (branch/PR + a short channel note).',
82
- ' 2. Answer any @mentions or thread follow-ups you missed — at most one reply per channel.',
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.',
83
98
  'If there is genuinely nothing outstanding, STOP silently — do NOT post a "nothing to do" message.',
84
99
  ].join('\n')
85
100
 
@@ -128,6 +143,14 @@ export async function runWatch({ flags }) {
128
143
  const workdir = chatOnly ? '' : (explicitWorkdir || (saved && (saved.workspace || saved.workdir)) || DEFAULT_WORKSPACE)
129
144
  if (workdir) { try { mkdirSync(workdir, { recursive: true }) } catch { /* best-effort; spawn will surface a real problem */ } }
130
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
+
131
154
  // Backend agents (connect --backend) drive autonomy over a real-time WS instead
132
155
  // of REST-polling the frontend relay. Detected by the saved mode / a --ws flag.
133
156
  const backendMode = (saved && saved.mode === 'backend') || !!flags.ws
@@ -139,7 +162,7 @@ export async function runWatch({ flags }) {
139
162
  if (!apiKey || !identifier) fail('No saved backend credentials for that agent.\n Run `openvisio-agent connect --backend …` first, or pass --key and --id.')
140
163
  assertWebSocket(fail)
141
164
  if (flags.install) return installService({ slug: slug || 'openvisio', claude, mcpConfig, workdir })
142
- 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 })
143
166
  }
144
167
 
145
168
  const host = stripSlash(flags.host || (saved && saved.host) || '')
@@ -148,16 +171,20 @@ export async function runWatch({ flags }) {
148
171
 
149
172
  if (flags.install) return installService({ slug: slug || 'openvisio', claude, mcpConfig, workdir })
150
173
 
151
- return loop({ host, key, slug: slug || 'openvisio', claude, mcpConfig, workdir, debug: !!flags.debug })
174
+ return loop({ host, key, slug: slug || 'openvisio', claude, mcpConfig, workdir, model, chatModel, debug: !!flags.debug })
152
175
  }
153
176
 
154
177
  // ── Claude Code warm-session cycle runner (shared by the REST + WS loops) ─────
155
178
  // One persistent stream-json session, poked with a prompt per cycle. Recycled
156
179
  // after MAX_TURNS or SESSION_IDLE_MS. Returns { runCycle, canCode }.
157
- function createCycleRunner({ claude, mcpConfig, workdir, log, debug }) {
180
+ function createCycleRunner({ claude, mcpConfig, workdir, log, debug, model }) {
158
181
  const canCode = !!workdir
159
182
  const maxCycleMs = canCode ? MAX_CODE_CYCLE_MS : MAX_CYCLE_MS
160
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
161
188
  let turnsThisSession = 0
162
189
  let sessionStartedAt = 0
163
190
  let resolveTurn = null
@@ -183,7 +210,7 @@ function createCycleRunner({ claude, mcpConfig, workdir, log, debug }) {
183
210
  function ensureSession() {
184
211
  if (child && !child.killed) return
185
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.') }
186
- 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] : [])]
187
214
  const args = canCode ? [...base, '--allowedTools', ...CODE_TOOLS, '--disallowedTools', ...DENY_TOOLS] : [...base, '--allowedTools', 'mcp__openvisio-team__*']
188
215
  const c = spawn(claude, args, { cwd: workdir || undefined, stdio: ['pipe', 'pipe', 'inherit'] })
189
216
  child = c
@@ -204,13 +231,19 @@ function createCycleRunner({ claude, mcpConfig, workdir, log, debug }) {
204
231
  })
205
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' }) })
206
233
  c.on('error', () => { if (c !== child) return; child = null; settleTurn({ type: 'result', subtype: 'error' }) })
207
- log('warm session started' + (canCode
234
+ log('warm session started' + (sessionModel ? ' [model ' + sessionModel + ']' : '') + (canCode
208
235
  ? ' [CODE mode — workspace ' + workdir + ' — finds/clones the org\'s repos here, branches, pushes, opens PRs]'
209
236
  : ' [CHAT-ONLY mode (--chat-only) — chat/ticket tools only, no code work]'))
210
237
  }
211
238
 
212
- function runCycle(prompt) {
239
+ function runCycle(prompt, cycleModel) {
213
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
+ }
214
247
  if (child && (turnsThisSession >= MAX_TURNS || Date.now() - sessionStartedAt > SESSION_IDLE_MS)) {
215
248
  log('recycling session (turns=' + turnsThisSession + ')')
216
249
  try { child.kill() } catch { /* already gone */ }
@@ -241,11 +274,16 @@ function createCycleRunner({ claude, mcpConfig, workdir, log, debug }) {
241
274
  // over the WS; each pushes ONE Claude cycle. Serialized (one cycle at a time) — events
242
275
  // arriving while busy are coalesced into a single follow-up cycle so a burst doesn't
243
276
  // stack up N sessions. Plus a one-time intro on first connect and a daily catch-up sweep.
244
- function loopBackendWs({ wsUrl, apiKey, identifier, claude, mcpConfig, workdir, debug }) {
277
+ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, mcpConfig, workdir, model, chatModel, debug }) {
245
278
  const log = (m) => process.stdout.write('[ws ' + new Date().toISOString() + '] ' + m + '\n')
246
- const { runCycle, canCode } = createCycleRunner({ claude, mcpConfig, workdir, log, debug })
279
+ const { runCycle, canCode } = createCycleRunner({ claude, mcpConfig, workdir, log, debug, model })
247
280
  const fullPrompt = canCode ? CODE_FULL : CYCLE
248
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
249
287
 
250
288
  let busy = false
251
289
  let queued = null // 'full' | 'fast' | 'sweep' | 'intro' — a cycle requested while one was running
@@ -254,6 +292,10 @@ function loopBackendWs({ wsUrl, apiKey, identifier, claude, mcpConfig, workdir,
254
292
  // different agent re-triggers), so a noisy stream of task:updated events doesn't
255
293
  // re-acknowledge the same assignment.
256
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()
257
299
  // Context lines from the events themselves (the WS payload already carries the
258
300
  // channel + message / task), so the agent acts on THEM directly instead of
259
301
  // hoping poll_inbox re-surfaces the same item. Accumulated across coalesced
@@ -275,9 +317,12 @@ function loopBackendWs({ wsUrl, apiKey, identifier, claude, mcpConfig, workdir,
275
317
  busy = true
276
318
  const ctx = pending.splice(0) // take everything accumulated so far
277
319
  const prompt = credNote + '\n\n' + (ctx.length ? ctx.join('\n') + '\n\n' : '') + baseFor(kind)
278
- log('running ' + kind + ' cycle…' + (ctx.length ? ' (' + ctx.length + ' event' + (ctx.length === 1 ? '' : 's') + ')' : ''))
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 + ']' : ''))
279
324
  try {
280
- await runCycle(prompt)
325
+ await runCycle(prompt, useModel)
281
326
  } finally {
282
327
  busy = false
283
328
  if (queued || pending.length) { const next = queued || 'fast'; queued = null; void drain(next) }
@@ -292,6 +337,30 @@ function loopBackendWs({ wsUrl, apiKey, identifier, claude, mcpConfig, workdir,
292
337
  return (`${s.first_name || ''} ${s.last_name || ''}`.trim() || s.name || s.email || '')
293
338
  }
294
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
+
295
364
  function onEvent(k, d) {
296
365
  const raw = d && typeof d === 'object' ? d : {}
297
366
  // The backend has NO `task:assigned` event — a task assigned to an agent arrives
@@ -323,9 +392,33 @@ function loopBackendWs({ wsUrl, apiKey, identifier, claude, mcpConfig, workdir,
323
392
  const mid = msg.id != null ? msg.id : (msg.message_id != null ? msg.message_id : null)
324
393
  const parent = msg.parent_id != null ? msg.parent_id : (msg.parentId != null ? msg.parentId : null)
325
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
+ }
326
419
  log('agent:mention in channel ' + (cid != null ? cid : '?') + (threadRoot != null ? ' (thread ' + threadRoot + ')' : ''))
327
420
  const ctx = cid != null
328
- ? `You were @mentioned in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". Reply 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.${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.`
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.`
329
422
  : undefined
330
423
  void drain('fast', ctx)
331
424
  } else if (k === 'error') {
@@ -355,7 +448,9 @@ function loopBackendWs({ wsUrl, apiKey, identifier, claude, mcpConfig, workdir,
355
448
  introTimer = setTimeout(() => void drain('intro'), 5000) // let the socket subscribe first
356
449
  }
357
450
  // Catch-up sweep: shortly after startup (covers downtime) + once every day.
358
- sweepStartTimer = setTimeout(() => { log('startup catch-up sweep'); void drain('sweep', SWEEP) }, 30_000)
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)
359
454
  sweepTimer = setInterval(() => { log('daily catch-up sweep'); void drain('sweep', SWEEP) }, DAY_MS)
360
455
  } else {
361
456
  log('no MCP config — skipping intro + daily sweep (agent has no tools to post/act)')
@@ -377,12 +472,13 @@ function loopBackendWs({ wsUrl, apiKey, identifier, claude, mcpConfig, workdir,
377
472
  }
378
473
 
379
474
  // ── the warm loop ────────────────────────────────────────────────────────────
380
- function loop({ host, key, slug, claude, mcpConfig, workdir, debug }) {
475
+ function loop({ host, key, slug, claude, mcpConfig, workdir, model, chatModel, debug }) {
381
476
  const log = (m) => process.stdout.write('[warm ' + new Date().toISOString() + '] ' + m + '\n')
382
477
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
383
- const { runCycle, canCode } = createCycleRunner({ claude, mcpConfig, workdir, log, debug })
478
+ const { runCycle, canCode } = createCycleRunner({ claude, mcpConfig, workdir, log, debug, model })
384
479
  const fullPrompt = canCode ? CODE_FULL : CYCLE
385
480
  const fastPrompt = canCode ? CODE_FAST : CYCLE_FAST
481
+ const liteModel = chatModel || model // cheaper model for chat/quick cycles
386
482
 
387
483
  const seen = new Set()
388
484
  let busy = false
@@ -401,7 +497,7 @@ function loop({ host, key, slug, claude, mcpConfig, workdir, debug }) {
401
497
  // The startup poll marks pre-existing tasks as "seen" (so it won't re-handle old
402
498
  // ones) — which would also skip tasks assigned while offline. A startup + daily
403
499
  // sweep re-checks get_marching_orders so those are still picked up.
404
- setTimeout(() => { log('startup catch-up sweep'); queuedSpecial = SWEEP + '\n\n' + fullPrompt }, 30_000)
500
+ setTimeout(() => { log('startup catch-up sweep'); queuedSpecial = SWEEP + '\n\n' + fullPrompt }, 12_000)
405
501
  setInterval(() => { log('daily catch-up sweep'); queuedSpecial = SWEEP + '\n\n' + fullPrompt }, 24 * 60 * 60 * 1000)
406
502
  }
407
503
 
@@ -428,7 +524,7 @@ function loop({ host, key, slug, claude, mcpConfig, workdir, debug }) {
428
524
  if (!busy && queuedSpecial) {
429
525
  const p = queuedSpecial; queuedSpecial = null
430
526
  busy = true
431
- try { await runCycle(p) } finally { busy = false; lastNewAt = Date.now() }
527
+ try { await runCycle(p, model) } finally { busy = false; lastNewAt = Date.now() }
432
528
  await sleep(FAST)
433
529
  continue
434
530
  }
@@ -449,14 +545,14 @@ function loop({ host, key, slug, claude, mcpConfig, workdir, debug }) {
449
545
  const q = await quickReply()
450
546
  if (q && q.ok) {
451
547
  log(fresh.length + ' new -> quick reply (' + (q.replied || 0) + ' posted)')
452
- 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) }
453
549
  } else {
454
550
  log(fresh.length + ' new -> fast reply (claude fallback)')
455
- await runCycle(fastPrompt)
551
+ await runCycle(fastPrompt, liteModel)
456
552
  }
457
553
  } else {
458
554
  log(fresh.length + ' new item(s) -> full cycle' + (canCode ? ' [code]' : ''))
459
- await runCycle(fullPrompt)
555
+ await runCycle(fullPrompt, model)
460
556
  }
461
557
  for (const i of items) seen.add(i)
462
558
  if (seen.size > 500) { seen.clear(); for (const i of items) seen.add(i) }