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.
Files changed (3) hide show
  1. package/bin/cli.mjs +12 -3
  2. package/package.json +1 -1
  3. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.4.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
@@ -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, 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,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, post that reply FIRST with mcp__openvisio-team__post_message — before any Bash.',
63
- 'Then poll_inbox and handle .followUps (thread replies you are part of, even without an @mention), at most one reply per channel.',
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:assigned / agent:mention over the WS; each
223
- // pushes ONE Claude cycle. Serialized (one cycle at a time) — events arriving
224
- // while busy are coalesced into a single follow-up cycle so a burst of mentions
225
- // doesn't stack up N sessions.
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 = (queued === 'full' || kind === 'full') ? 'full' : 'fast'; log('busy — queued a ' + kind + ' follow-up cycle'); return }
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 base = kind === 'full' ? fullPrompt : fastPrompt
252
- const prompt = credNote + '\n\n' + (ctx.length ? ctx.join('\n') + '\n\n' : '') + base
253
- log('running ' + kind + ' cycle…' + (ctx.length ? ' (' + ctx.length + ' event' + (ctx.length === 1 ? '' : 's') + ')' : ''))
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
- if (k === 'task:assigned') {
273
- const t = raw.task || {}
274
- log('task:assigned ' + (t.id != null ? '#' + t.id + ' “' + (t.title || '') + '”' : ''))
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', t.id != null ? `You were ASSIGNED task #${t.id}: "${t.title || ''}"${desc}. Handle it, then comment_ticket with a short summary.` : undefined)
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}". 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.`
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
- // NO polling — the WebSocket is the only trigger (task:assigned / agent:mention).
301
- // A thread reply that @mentions the agent fires agent:mention and is handled
302
- // in-thread above; genuinely un-mentioned thread activity has no WS event, so
303
- // it's a backend concern (dispatch a thread event to participant agents), not a
304
- // reason to poll.
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 = () => { try { handle && handle.close() } catch { /* noop */ } process.exit(0) }
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) }