openvisio-agent 0.3.7 → 0.5.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 +30 -12
- package/package.json +1 -1
- package/src/lib.mjs +6 -0
- package/src/watch.mjs +196 -35
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] [--
|
|
29
|
+
openvisio-agent watch --name <agent> [--install] [--workspace <dir>] [--chat-only] [--debug]
|
|
30
30
|
openvisio-agent --help | --version
|
|
31
31
|
|
|
32
32
|
connect
|
|
@@ -45,14 +45,23 @@ connect --backend
|
|
|
45
45
|
— enables real-time autonomy (task:assigned / agent:mention).
|
|
46
46
|
--mcp-url <url> registers the openvisio-team MCP so the agent has tools to ACT
|
|
47
47
|
on those events. Needs Node >= 21 for the WebSocket.
|
|
48
|
-
--
|
|
48
|
+
--workspace <dir> where the agent works. CODE mode is ON BY DEFAULT — it finds
|
|
49
|
+
the org's repos here (cloning any it doesn't have yet), branches,
|
|
50
|
+
pushes its agent/* branch and opens PRs. Defaults to
|
|
51
|
+
~/openvisio-workspace; point it at an existing clones folder to
|
|
52
|
+
reuse those. (--workdir is an accepted alias.)
|
|
53
|
+
--chat-only disable code work — chat/ticket tools only.
|
|
49
54
|
--no-service skip the background service — just save config + print the
|
|
50
55
|
watch commands to run yourself.
|
|
51
56
|
|
|
52
57
|
watch
|
|
53
|
-
Runs the event-driven autonomy loop (reply to mentions, pick up tickets).
|
|
54
|
-
|
|
55
|
-
|
|
58
|
+
Runs the event-driven autonomy loop (reply to mentions, pick up tickets). CODE
|
|
59
|
+
mode is ON BY DEFAULT: the agent finds the org's repos in its workspace
|
|
60
|
+
(~/openvisio-workspace, cloning any it doesn't have yet), branches, commits, pushes
|
|
61
|
+
its agent/* branch, opens PRs, then circles back with the PR link and @mentions the
|
|
62
|
+
requester — never touching main/master, never force-pushing or merging. No per-repo
|
|
63
|
+
setup needed. Use --workspace <dir> to relocate it (e.g. an existing clones folder),
|
|
64
|
+
--chat-only to disable code work, and --install to run in the background on login.
|
|
56
65
|
|
|
57
66
|
Docs: https://www.npmjs.com/package/openvisio-agent`
|
|
58
67
|
|
|
@@ -100,16 +109,19 @@ async function runConnect({ positional, flags }) {
|
|
|
100
109
|
// A scoped MCP config (for the watcher's --strict-mcp-config) + a saved profile.
|
|
101
110
|
const mcpCfg = mcpConfigPath(slug)
|
|
102
111
|
writeJson(mcpCfg, { mcpServers: { 'openvisio-team': { type: 'http', url: mcpUrl, headers: { Authorization: `Bearer ${key}` } } } }, true)
|
|
103
|
-
|
|
112
|
+
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)
|
|
104
114
|
|
|
105
115
|
ok(`Connected "${name}" to ${host}.`)
|
|
106
116
|
info()
|
|
107
117
|
info('Claude Code now has the openvisio-team tools. Run /mcp in Claude Code to confirm.')
|
|
108
118
|
info()
|
|
109
|
-
info('To let it work on its own (reply to mentions, pick up tickets
|
|
110
|
-
info(
|
|
111
|
-
info(` openvisio-agent watch --name ${slug}
|
|
112
|
-
info(` openvisio-agent watch --name ${slug} --
|
|
119
|
+
info('To let it work on its own (reply to mentions, pick up tickets, AND do real')
|
|
120
|
+
info('coding — it clones/branches/pushes and opens PRs out of the box):')
|
|
121
|
+
info(` openvisio-agent watch --name ${slug} # run now, in this terminal`)
|
|
122
|
+
info(` openvisio-agent watch --name ${slug} --install # background, auto-start on login`)
|
|
123
|
+
info(` openvisio-agent watch --name ${slug} --workspace <dir> # reuse an existing clones folder`)
|
|
124
|
+
info(` openvisio-agent watch --name ${slug} --chat-only # disable code work (chat/tickets only)`)
|
|
113
125
|
}
|
|
114
126
|
|
|
115
127
|
// Backend mode — for agents created against the OpenVisio ORG BACKEND
|
|
@@ -140,6 +152,13 @@ async function runConnectBackend({ flags }) {
|
|
|
140
152
|
const name = (flags.name && String(flags.name)) || 'backend-agent'
|
|
141
153
|
const slug = slugify(name)
|
|
142
154
|
|
|
155
|
+
// Code workspace preference (optional). CODE mode is on by default; this only
|
|
156
|
+
// pins WHERE the agent works. Persisted so the always-on `watch` reuses it.
|
|
157
|
+
const chatOnly = !!flags['chat-only']
|
|
158
|
+
const workdir = flags.workdir === true ? process.cwd()
|
|
159
|
+
: flags.workdir ? String(flags.workdir)
|
|
160
|
+
: flags.workspace ? String(flags.workspace) : ''
|
|
161
|
+
|
|
143
162
|
// Optional real-time autonomy: --ws is the org's API-Gateway WS base (same value
|
|
144
163
|
// as the frontend's NEXT_PUBLIC_BACKEND_WS_URL). --mcp-url gives the agent a tool
|
|
145
164
|
// surface so WS events (task:assigned / agent:mention) can drive a Claude cycle.
|
|
@@ -157,7 +176,7 @@ async function runConnectBackend({ flags }) {
|
|
|
157
176
|
writeJson(mcpConfig, { mcpServers: { 'openvisio-team': { type: 'http', url: mcpUrl, headers: { 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier } } } }, true)
|
|
158
177
|
}
|
|
159
178
|
|
|
160
|
-
writeJson(configPath(slug), { mode: 'backend', backend, apiKey, identifier, name, slug, wsUrl, mcpUrl, mcpConfig }, true)
|
|
179
|
+
writeJson(configPath(slug), { mode: 'backend', backend, apiKey, identifier, name, slug, wsUrl, mcpUrl, mcpConfig, ...(workdir ? { workspace: workdir } : {}), ...(chatOnly ? { chatOnly: true } : {}) }, true)
|
|
161
180
|
// A sourceable env file, matching the setup snippet OpenVisio shows.
|
|
162
181
|
const envPath = join(OV_DIR, `${slug}.env`)
|
|
163
182
|
mkdirSync(OV_DIR, { recursive: true })
|
|
@@ -188,7 +207,6 @@ async function runConnectBackend({ flags }) {
|
|
|
188
207
|
// Real-time autonomy IS the point of a backend agent: install the command and a
|
|
189
208
|
// background service that auto-starts on login, so a mention is always caught —
|
|
190
209
|
// no terminal left open, survives reboot. Opt out with --no-service.
|
|
191
|
-
const workdir = flags.workdir === true ? process.cwd() : (flags.workdir ? String(flags.workdir) : '')
|
|
192
210
|
if (flags['no-service'] || process.platform === 'win32') {
|
|
193
211
|
ensureAgentInstalled()
|
|
194
212
|
info()
|
package/package.json
CHANGED
package/src/lib.mjs
CHANGED
|
@@ -7,6 +7,12 @@ import { join } from 'node:path'
|
|
|
7
7
|
import { mkdirSync, writeFileSync, readFileSync, chmodSync } from 'node:fs'
|
|
8
8
|
|
|
9
9
|
export const OV_DIR = join(homedir(), '.openvisio')
|
|
10
|
+
// The agent's default code WORKSPACE — a single dedicated root that holds the org's
|
|
11
|
+
// repos as subfolders. Code mode is on by default and the agent clones missing
|
|
12
|
+
// repos in here, so a coding assistant works out of the box with NO per-repo config
|
|
13
|
+
// (override with --workdir/--workspace to point at an existing clones folder). Kept
|
|
14
|
+
// separate from the user's own checkouts so the agent never stomps a working tree.
|
|
15
|
+
export const DEFAULT_WORKSPACE = join(homedir(), 'openvisio-workspace')
|
|
10
16
|
const IS_WIN = process.platform === 'win32'
|
|
11
17
|
|
|
12
18
|
export function fail(msg) { console.error('✖ ' + msg); process.exit(1) }
|
package/src/watch.mjs
CHANGED
|
@@ -5,22 +5,97 @@
|
|
|
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, readConfig, onPath, fail, ok, info, slugify, stripSlash, chmodSafe } from './lib.mjs'
|
|
11
|
+
import { OV_DIR, DEFAULT_WORKSPACE, readConfig, 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
|
|
15
15
|
// credentials as ARGUMENTS on every tool call — those are injected at runtime by
|
|
16
16
|
// loopBackendWs (see credNote), NOT baked in here, so nothing needs hunting.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
//
|
|
18
|
+
// Two recurring failures these prompts fix head-on: (1) the agent UNDER-READS its
|
|
19
|
+
// own capabilities — says "I can't check a codebase" when it can — and (2) it
|
|
20
|
+
// PROMISES work then stops, forcing the human to remind it to circle back. The
|
|
21
|
+
// CHARTER blocks below assert the toolbox and mandate closing the loop in-cycle.
|
|
21
22
|
|
|
23
|
+
// ── CHAT-ONLY agents (no --workdir): chat/ticket tools, no code surface. ──────
|
|
24
|
+
const CHAT_CHARTER = [
|
|
25
|
+
'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
|
+
'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.',
|
|
27
|
+
].join('\n')
|
|
28
|
+
|
|
29
|
+
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.'
|
|
31
|
+
|
|
32
|
+
// ── CODE agents (--workdir given): full file + Bash + git/gh surface. ─────────
|
|
33
|
+
// A stable "who you are / how you work" charter prepended to every code cycle.
|
|
34
|
+
const CODE_CHARTER = [
|
|
35
|
+
'YOU ARE a connected CODING agent in an OpenVisio team, running ON THE USER\'S LAPTOP. You have REAL tools — use them; do NOT claim you lack a capability without checking what you actually hold. Your toolbox:',
|
|
36
|
+
' • openvisio-team chat/ticket tools (mcp__openvisio-team__*) — read mentions, post messages, comment on tickets, react.',
|
|
37
|
+
' • Read / Grep / Glob / Edit / Write / MultiEdit — inspect AND change code.',
|
|
38
|
+
' • Bash — git (branch, commit, push a branch), gh (clone repos, open PRs), run tests/builds.',
|
|
39
|
+
'YOUR WORKSPACE: your working directory is a WORKSPACE ROOT that holds the org\'s repos as subfolders. You are NOT limited to one repo — you work across ALL of them from here. For any task: find the relevant repo under the workspace; if it isn\'t cloned yet, CLONE it (gh repo clone <org>/<repo>, or git clone <url>) and then work inside that subfolder. Never make the user tell you a path or clone things for you.',
|
|
40
|
+
'CAPABILITY CHECK: before you EVER answer "I can\'t do that", verify against the tools above. If a tool exists for it, DO it. To be explicit: you CAN read/inspect any of the org\'s codebases, clone a repo you don\'t have yet, work on it, create a branch, and raise a PR — say YES to these and then actually do them.',
|
|
41
|
+
'',
|
|
42
|
+
'WORK ETHIC — how a reliable teammate behaves (this is the difference between useful and ignored):',
|
|
43
|
+
' 1. CLOSE THE LOOP in THIS cycle. Never say "I\'ll do X" and stop. If you commit to something, do it NOW — the human must never have to remind you to circle back.',
|
|
44
|
+
' 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
|
+
' 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
|
+
' 4. One reply per channel per cycle; answer several nudges together.',
|
|
47
|
+
].join('\n')
|
|
48
|
+
|
|
49
|
+
const CODE_FULL = CODE_CHARTER + '\n\n' + [
|
|
50
|
+
'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.',
|
|
52
|
+
'For real code work (an assigned ticket, or a mention asking for changes), run the full flow end-to-end:',
|
|
53
|
+
' 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
|
+
' 2. BRANCH: git checkout -B agent/<short-task-slug>. NEVER work on, commit to, or push main/master.',
|
|
55
|
+
' 3. CHANGE + VERIFY: Read/Edit/Write the files; run the tests or build if the repo has them.',
|
|
56
|
+
' 4. COMMIT + PUSH YOUR BRANCH: git add -A && git commit -m "…"; then git push -u origin agent/<slug>. Only ever push your own agent/* branch. Never --force, never push to main/master, never merge.',
|
|
57
|
+
' 5. RAISE A PR: gh pr create --fill --base <default-branch> --head agent/<slug> (a clear title + a body summarizing the change and how you verified it). Never gh pr merge.',
|
|
58
|
+
' 6. CLOSE THE LOOP: comment_ticket with the summary + PR link/branch, and post a channel reply that @mentions the requester by their exact full name.',
|
|
59
|
+
'Bash is for git / gh / tests / clone ONLY — never to hunt for credentials (they are given to you above).',
|
|
60
|
+
].join('\n')
|
|
61
|
+
|
|
62
|
+
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.',
|
|
66
|
+
'Do NOT promise and stop — finish and report in THIS cycle. Reply 1-3 sentences, no summary. Then stop.',
|
|
67
|
+
].join('\n')
|
|
68
|
+
|
|
69
|
+
// ── Workspace-ethics cycles (both chat-only + code agents) ───────────────────
|
|
70
|
+
// INTRO: a one-time hello when the agent first joins a workspace. SWEEP: a daily
|
|
71
|
+
// (and on-startup) catch-up so nothing assigned while the agent was offline is
|
|
72
|
+
// missed — TASKS especially.
|
|
73
|
+
const INTRO = [
|
|
74
|
+
'You have just JOINED this OpenVisio workspace (your first connection). Workspace etiquette: introduce yourself so the team knows you are here and reachable.',
|
|
75
|
+
'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.',
|
|
76
|
+
'Post it EXACTLY ONCE, then stop. Do NOT do any other work this cycle.',
|
|
77
|
+
].join('\n')
|
|
78
|
+
const SWEEP = [
|
|
79
|
+
'DAILY CATCH-UP — you may have missed items while offline. Prioritize TASKS.',
|
|
80
|
+
'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.',
|
|
83
|
+
'If there is genuinely nothing outstanding, STOP silently — do NOT post a "nothing to do" message.',
|
|
84
|
+
].join('\n')
|
|
85
|
+
|
|
86
|
+
// Bash covers git/gh/clone/tests; the deny list is where the guardrails live.
|
|
22
87
|
const CODE_TOOLS = ['Read', 'Grep', 'Glob', 'Edit', 'Write', 'MultiEdit', 'TodoWrite', 'Bash', 'mcp__openvisio-team__*']
|
|
23
|
-
|
|
88
|
+
// Push + PR creation ARE allowed (agents raise PRs), but main/master, force-pushes,
|
|
89
|
+
// merges and destructive/publishing/repo-deleting commands stay blocked.
|
|
90
|
+
const DENY_TOOLS = [
|
|
91
|
+
'Bash(git push --force:*)', 'Bash(git push -f:*)', 'Bash(git push --force-with-lease:*)',
|
|
92
|
+
'Bash(git push origin main:*)', 'Bash(git push origin master:*)',
|
|
93
|
+
'Bash(git push origin HEAD:main:*)', 'Bash(git push origin HEAD:master:*)',
|
|
94
|
+
'Bash(git reset --hard:*)', 'Bash(git clean:*)',
|
|
95
|
+
'Bash(rm:*)', 'Bash(sudo:*)', 'Bash(chmod:*)', 'Bash(curl:*)', 'Bash(wget:*)',
|
|
96
|
+
'Bash(npm publish:*)', 'Bash(pnpm publish:*)',
|
|
97
|
+
'Bash(gh pr merge:*)', 'Bash(gh repo delete:*)',
|
|
98
|
+
]
|
|
24
99
|
|
|
25
100
|
const FAST = 2500
|
|
26
101
|
const SLOW = 6000
|
|
@@ -29,15 +104,29 @@ const MAX_TURNS = 15
|
|
|
29
104
|
const SESSION_IDLE_MS = 1200000
|
|
30
105
|
// A single cycle must finish within this or it's abandoned — otherwise a hung
|
|
31
106
|
// cycle (e.g. an MCP tool stalling on a down bridge) would leave `busy` stuck
|
|
32
|
-
// true forever and silently queue every later mention.
|
|
107
|
+
// true forever and silently queue every later mention. Code cycles get a much
|
|
108
|
+
// longer budget: a real clone → branch → test → push → PR flow legitimately takes
|
|
109
|
+
// minutes, and cutting it off mid-job is itself a "never circled back" failure.
|
|
33
110
|
const MAX_CYCLE_MS = 240000
|
|
111
|
+
const MAX_CODE_CYCLE_MS = 900000
|
|
34
112
|
|
|
35
113
|
export async function runWatch({ flags }) {
|
|
36
114
|
const slug = flags.name ? slugify(String(flags.name)) : null
|
|
37
115
|
const saved = slug ? readConfig(slug) : null
|
|
38
116
|
const claude = String(flags.claude || onPath('claude') || 'claude')
|
|
39
117
|
const mcpConfig = String(flags['mcp-config'] || (saved && saved.mcpConfig) || '')
|
|
40
|
-
|
|
118
|
+
// Code mode is the DEFAULT: an agent lives on the user's laptop and should just be
|
|
119
|
+
// able to work across the org's repos with no per-repo setup. So `workdir` resolves
|
|
120
|
+
// to (in order) an explicit --workdir/--workspace, the saved workspace, or the
|
|
121
|
+
// shared DEFAULT_WORKSPACE — always non-empty unless the agent is opted into
|
|
122
|
+
// --chat-only. It's a WORKSPACE ROOT (holds repos as subfolders + clones missing
|
|
123
|
+
// ones), not a single repo. Created on demand so the Claude session can cwd into it.
|
|
124
|
+
const chatOnly = !!flags['chat-only'] || (saved && saved.chatOnly === true)
|
|
125
|
+
const explicitWorkdir = flags.workdir === true ? process.cwd()
|
|
126
|
+
: flags.workdir ? String(flags.workdir)
|
|
127
|
+
: flags.workspace ? String(flags.workspace) : ''
|
|
128
|
+
const workdir = chatOnly ? '' : (explicitWorkdir || (saved && (saved.workspace || saved.workdir)) || DEFAULT_WORKSPACE)
|
|
129
|
+
if (workdir) { try { mkdirSync(workdir, { recursive: true }) } catch { /* best-effort; spawn will surface a real problem */ } }
|
|
41
130
|
|
|
42
131
|
// Backend agents (connect --backend) drive autonomy over a real-time WS instead
|
|
43
132
|
// of REST-polling the frontend relay. Detected by the saved mode / a --ws flag.
|
|
@@ -59,7 +148,7 @@ export async function runWatch({ flags }) {
|
|
|
59
148
|
|
|
60
149
|
if (flags.install) return installService({ slug: slug || 'openvisio', claude, mcpConfig, workdir })
|
|
61
150
|
|
|
62
|
-
return loop({ host, key, claude, mcpConfig, workdir, debug: !!flags.debug })
|
|
151
|
+
return loop({ host, key, slug: slug || 'openvisio', claude, mcpConfig, workdir, debug: !!flags.debug })
|
|
63
152
|
}
|
|
64
153
|
|
|
65
154
|
// ── Claude Code warm-session cycle runner (shared by the REST + WS loops) ─────
|
|
@@ -67,6 +156,7 @@ export async function runWatch({ flags }) {
|
|
|
67
156
|
// after MAX_TURNS or SESSION_IDLE_MS. Returns { runCycle, canCode }.
|
|
68
157
|
function createCycleRunner({ claude, mcpConfig, workdir, log, debug }) {
|
|
69
158
|
const canCode = !!workdir
|
|
159
|
+
const maxCycleMs = canCode ? MAX_CODE_CYCLE_MS : MAX_CYCLE_MS
|
|
70
160
|
let child = null
|
|
71
161
|
let turnsThisSession = 0
|
|
72
162
|
let sessionStartedAt = 0
|
|
@@ -114,7 +204,9 @@ function createCycleRunner({ claude, mcpConfig, workdir, log, debug }) {
|
|
|
114
204
|
})
|
|
115
205
|
c.on('exit', (code) => { if (c !== child) { log('old session exited ' + code); return } log('session exited ' + code); child = null; settleTurn({ type: 'result', subtype: 'exit' }) })
|
|
116
206
|
c.on('error', () => { if (c !== child) return; child = null; settleTurn({ type: 'result', subtype: 'error' }) })
|
|
117
|
-
log('warm session started'
|
|
207
|
+
log('warm session started' + (canCode
|
|
208
|
+
? ' [CODE mode — workspace ' + workdir + ' — finds/clones the org\'s repos here, branches, pushes, opens PRs]'
|
|
209
|
+
: ' [CHAT-ONLY mode (--chat-only) — chat/ticket tools only, no code work]'))
|
|
118
210
|
}
|
|
119
211
|
|
|
120
212
|
function runCycle(prompt) {
|
|
@@ -131,11 +223,11 @@ function createCycleRunner({ claude, mcpConfig, workdir, log, debug }) {
|
|
|
131
223
|
// and queued mentions can proceed.
|
|
132
224
|
clearCycleTimer()
|
|
133
225
|
cycleTimer = setTimeout(() => {
|
|
134
|
-
log('cycle TIMED OUT after ' + Math.round(
|
|
226
|
+
log('cycle TIMED OUT after ' + Math.round(maxCycleMs / 1000) + 's — killing the session so the queue can proceed')
|
|
135
227
|
try { child && child.kill() } catch { /* already gone */ }
|
|
136
228
|
child = null
|
|
137
229
|
settleTurn({ type: 'result', subtype: 'timeout' })
|
|
138
|
-
},
|
|
230
|
+
}, maxCycleMs)
|
|
139
231
|
try { child.stdin.write(JSON.stringify({ type: 'user', message: { role: 'user', content: prompt } }) + '\n') }
|
|
140
232
|
catch { settleTurn({ type: 'result', subtype: 'write-failed' }) }
|
|
141
233
|
})
|
|
@@ -145,10 +237,10 @@ function createCycleRunner({ claude, mcpConfig, workdir, log, debug }) {
|
|
|
145
237
|
}
|
|
146
238
|
|
|
147
239
|
// ── the backend WS loop ──────────────────────────────────────────────────────
|
|
148
|
-
// Real-time: the backend pushes task:
|
|
149
|
-
// pushes ONE Claude cycle. Serialized (one cycle at a time) — events
|
|
150
|
-
// while busy are coalesced into a single follow-up cycle so a burst
|
|
151
|
-
//
|
|
240
|
+
// Real-time: the backend pushes task:created/task:updated (assignments) + agent:mention
|
|
241
|
+
// over the WS; each pushes ONE Claude cycle. Serialized (one cycle at a time) — events
|
|
242
|
+
// arriving while busy are coalesced into a single follow-up cycle so a burst doesn't
|
|
243
|
+
// stack up N sessions. Plus a one-time intro on first connect and a daily catch-up sweep.
|
|
152
244
|
function loopBackendWs({ wsUrl, apiKey, identifier, claude, mcpConfig, workdir, debug }) {
|
|
153
245
|
const log = (m) => process.stdout.write('[ws ' + new Date().toISOString() + '] ' + m + '\n')
|
|
154
246
|
const { runCycle, canCode } = createCycleRunner({ claude, mcpConfig, workdir, log, debug })
|
|
@@ -156,8 +248,12 @@ function loopBackendWs({ wsUrl, apiKey, identifier, claude, mcpConfig, workdir,
|
|
|
156
248
|
const fastPrompt = canCode ? CODE_FAST : CYCLE_FAST
|
|
157
249
|
|
|
158
250
|
let busy = false
|
|
159
|
-
let queued = null // 'full' | 'fast' — a cycle requested while one was running
|
|
251
|
+
let queued = null // 'full' | 'fast' | 'sweep' | 'intro' — a cycle requested while one was running
|
|
160
252
|
let handle = null
|
|
253
|
+
// Tasks we've already reacted to (keyed task-id:agent — so a REASSIGNMENT to a
|
|
254
|
+
// different agent re-triggers), so a noisy stream of task:updated events doesn't
|
|
255
|
+
// re-acknowledge the same assignment.
|
|
256
|
+
const seenTasks = new Set()
|
|
161
257
|
// Context lines from the events themselves (the WS payload already carries the
|
|
162
258
|
// channel + message / task), so the agent acts on THEM directly instead of
|
|
163
259
|
// hoping poll_inbox re-surfaces the same item. Accumulated across coalesced
|
|
@@ -167,15 +263,18 @@ function loopBackendWs({ wsUrl, apiKey, identifier, claude, mcpConfig, workdir,
|
|
|
167
263
|
// The Mastra bridge authenticates per-CALL, not per-connection: every
|
|
168
264
|
// openvisio-team tool needs agent_identifier + agent_api_key as arguments.
|
|
169
265
|
// Hand them to the model up front so it never shells around hunting for them.
|
|
170
|
-
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
|
|
266
|
+
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.)`
|
|
267
|
+
|
|
268
|
+
// Higher rank wins when coalescing cycles requested while one is running.
|
|
269
|
+
const RANK = { fast: 0, intro: 1, sweep: 2, full: 3 }
|
|
270
|
+
const baseFor = (kind) => kind === 'intro' ? INTRO : (kind === 'full' || kind === 'sweep') ? fullPrompt : fastPrompt
|
|
171
271
|
|
|
172
272
|
async function drain(kind, context) {
|
|
173
273
|
if (context) pending.push(context)
|
|
174
|
-
if (busy) { queued = (
|
|
274
|
+
if (busy) { queued = (RANK[kind] ?? 0) >= (RANK[queued] ?? 0) ? kind : queued; log('busy — queued a ' + kind + ' follow-up cycle'); return }
|
|
175
275
|
busy = true
|
|
176
276
|
const ctx = pending.splice(0) // take everything accumulated so far
|
|
177
|
-
const
|
|
178
|
-
const prompt = credNote + '\n\n' + (ctx.length ? ctx.join('\n') + '\n\n' : '') + base
|
|
277
|
+
const prompt = credNote + '\n\n' + (ctx.length ? ctx.join('\n') + '\n\n' : '') + baseFor(kind)
|
|
179
278
|
log('running ' + kind + ' cycle…' + (ctx.length ? ' (' + ctx.length + ' event' + (ctx.length === 1 ? '' : 's') + ')' : ''))
|
|
180
279
|
try {
|
|
181
280
|
await runCycle(prompt)
|
|
@@ -195,11 +294,25 @@ function loopBackendWs({ wsUrl, apiKey, identifier, claude, mcpConfig, workdir,
|
|
|
195
294
|
|
|
196
295
|
function onEvent(k, d) {
|
|
197
296
|
const raw = d && typeof d === 'object' ? d : {}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
297
|
+
// The backend has NO `task:assigned` event — a task assigned to an agent arrives
|
|
298
|
+
// as `task:created` (assigned on create) or `task:updated` (assignee changed),
|
|
299
|
+
// fanned out org-wide. Catch both, keep only agent-assigned tasks, and let the
|
|
300
|
+
// cycle confirm ownership via get_marching_orders before acting.
|
|
301
|
+
if (k === 'task:created' || k === 'task:updated') {
|
|
302
|
+
const t = raw.task && typeof raw.task === 'object' ? raw.task : {}
|
|
303
|
+
const agentId = t.agent_id != null ? t.agent_id : (t.agentId != null ? t.agentId : null)
|
|
304
|
+
if (agentId == null) return // not assigned to an agent — ignore
|
|
305
|
+
// If the payload carries the agent's identifier, filter precisely to US and skip
|
|
306
|
+
// other agents' tasks entirely; otherwise let get_marching_orders confirm.
|
|
307
|
+
const ag = (t.agent && typeof t.agent === 'object') ? t.agent : null
|
|
308
|
+
const agIdent = ag && (ag.identifier || ag.slug) ? String(ag.identifier || ag.slug) : null
|
|
309
|
+
if (agIdent != null && agIdent !== identifier) return
|
|
310
|
+
const key = `${t.id}:${agentId}`
|
|
311
|
+
if (t.id != null && seenTasks.has(key)) return
|
|
312
|
+
if (t.id != null) { seenTasks.add(key); if (seenTasks.size > 500) seenTasks.clear() }
|
|
313
|
+
log('task ' + k.slice(5) + ' #' + (t.id != null ? t.id : '?') + ' (agent ' + agentId + ') “' + (t.title || '') + '”')
|
|
201
314
|
const desc = t.description ? ' — ' + String(t.description).replace(/\s+/g, ' ').slice(0, 400) : ''
|
|
202
|
-
void drain('full',
|
|
315
|
+
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.`)
|
|
203
316
|
} else if (k === 'agent:mention') {
|
|
204
317
|
const cid = raw.channel_id != null ? raw.channel_id : (raw.channelId != null ? raw.channelId : null)
|
|
205
318
|
const msg = raw.message && typeof raw.message === 'object' ? raw.message : {}
|
|
@@ -223,25 +336,48 @@ function loopBackendWs({ wsUrl, apiKey, identifier, claude, mcpConfig, workdir,
|
|
|
223
336
|
}
|
|
224
337
|
}
|
|
225
338
|
|
|
226
|
-
//
|
|
227
|
-
// A thread reply that @mentions the agent fires agent:mention and
|
|
228
|
-
// in-thread above
|
|
229
|
-
//
|
|
230
|
-
//
|
|
339
|
+
// The WebSocket drives real-time reactions (task:created/updated assigned to us,
|
|
340
|
+
// agent:mention). A thread reply that @mentions the agent fires agent:mention and
|
|
341
|
+
// is handled in-thread above. In addition we run a DAILY (and on-startup) sweep
|
|
342
|
+
// so anything assigned while the agent was offline — tasks especially — is still
|
|
343
|
+
// picked up even though we don't hot-poll.
|
|
231
344
|
log('up — backend WS watcher on ' + wsUrl + (canCode ? ' [code: ' + workdir + ']' : ''))
|
|
232
345
|
handle = connectAgentWs({ wsUrl, apiKey, identifier, onEvent, log })
|
|
233
346
|
|
|
347
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
348
|
+
let introTimer = null, sweepStartTimer = null, sweepTimer = null
|
|
349
|
+
if (mcpConfig) {
|
|
350
|
+
// Workspace ethics: a one-time hello the FIRST time this agent ever connects.
|
|
351
|
+
const introMarker = join(OV_DIR, 'intro-' + slugify(identifier) + '.done')
|
|
352
|
+
if (!existsSync(introMarker)) {
|
|
353
|
+
try { mkdirSync(OV_DIR, { recursive: true }); writeFileSync(introMarker, new Date().toISOString() + '\n') } catch { /* best-effort */ }
|
|
354
|
+
log('first connection — introducing self to the workspace')
|
|
355
|
+
introTimer = setTimeout(() => void drain('intro'), 5000) // let the socket subscribe first
|
|
356
|
+
}
|
|
357
|
+
// 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)
|
|
359
|
+
sweepTimer = setInterval(() => { log('daily catch-up sweep'); void drain('sweep', SWEEP) }, DAY_MS)
|
|
360
|
+
} else {
|
|
361
|
+
log('no MCP config — skipping intro + daily sweep (agent has no tools to post/act)')
|
|
362
|
+
}
|
|
363
|
+
|
|
234
364
|
return new Promise(() => {
|
|
235
|
-
// Run until killed. Tidy up the socket on termination so a restarting
|
|
236
|
-
// service doesn't leak a half-open connection.
|
|
237
|
-
const bye = () => {
|
|
365
|
+
// Run until killed. Tidy up the socket + timers on termination so a restarting
|
|
366
|
+
// service doesn't leak a half-open connection or a dangling interval.
|
|
367
|
+
const bye = () => {
|
|
368
|
+
if (introTimer) clearTimeout(introTimer)
|
|
369
|
+
if (sweepStartTimer) clearTimeout(sweepStartTimer)
|
|
370
|
+
if (sweepTimer) clearInterval(sweepTimer)
|
|
371
|
+
try { handle && handle.close() } catch { /* noop */ }
|
|
372
|
+
process.exit(0)
|
|
373
|
+
}
|
|
238
374
|
process.on('SIGTERM', bye)
|
|
239
375
|
process.on('SIGINT', bye)
|
|
240
376
|
})
|
|
241
377
|
}
|
|
242
378
|
|
|
243
379
|
// ── the warm loop ────────────────────────────────────────────────────────────
|
|
244
|
-
function loop({ host, key, claude, mcpConfig, workdir, debug }) {
|
|
380
|
+
function loop({ host, key, slug, claude, mcpConfig, workdir, debug }) {
|
|
245
381
|
const log = (m) => process.stdout.write('[warm ' + new Date().toISOString() + '] ' + m + '\n')
|
|
246
382
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
247
383
|
const { runCycle, canCode } = createCycleRunner({ claude, mcpConfig, workdir, log, debug })
|
|
@@ -252,6 +388,22 @@ function loop({ host, key, claude, mcpConfig, workdir, debug }) {
|
|
|
252
388
|
let busy = false
|
|
253
389
|
let firstCheck = true
|
|
254
390
|
let lastNewAt = Date.now()
|
|
391
|
+
// A one-off prompt (intro / daily sweep) the main loop runs the next time it's
|
|
392
|
+
// free — keeps everything on the single runCycle so nothing overlaps.
|
|
393
|
+
let queuedSpecial = null
|
|
394
|
+
if (mcpConfig) {
|
|
395
|
+
const introMarker = join(OV_DIR, 'intro-' + (slug || 'openvisio') + '.done')
|
|
396
|
+
if (!existsSync(introMarker)) {
|
|
397
|
+
try { mkdirSync(OV_DIR, { recursive: true }); writeFileSync(introMarker, new Date().toISOString() + '\n') } catch { /* best-effort */ }
|
|
398
|
+
log('first run — introducing self to the workspace')
|
|
399
|
+
setTimeout(() => { queuedSpecial = INTRO }, 5000)
|
|
400
|
+
}
|
|
401
|
+
// The startup poll marks pre-existing tasks as "seen" (so it won't re-handle old
|
|
402
|
+
// ones) — which would also skip tasks assigned while offline. A startup + daily
|
|
403
|
+
// 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)
|
|
405
|
+
setInterval(() => { log('daily catch-up sweep'); queuedSpecial = SWEEP + '\n\n' + fullPrompt }, 24 * 60 * 60 * 1000)
|
|
406
|
+
}
|
|
255
407
|
|
|
256
408
|
async function check() {
|
|
257
409
|
const res = await fetch(host + '/api/agent/inbox', { headers: { authorization: 'Bearer ' + key } })
|
|
@@ -271,6 +423,15 @@ function loop({ host, key, claude, mcpConfig, workdir, debug }) {
|
|
|
271
423
|
for (;;) {
|
|
272
424
|
let delay = FAST
|
|
273
425
|
try {
|
|
426
|
+
// Run a queued one-off (intro / daily sweep) first when free, on the same
|
|
427
|
+
// runCycle so it never overlaps a normal cycle.
|
|
428
|
+
if (!busy && queuedSpecial) {
|
|
429
|
+
const p = queuedSpecial; queuedSpecial = null
|
|
430
|
+
busy = true
|
|
431
|
+
try { await runCycle(p) } finally { busy = false; lastNewAt = Date.now() }
|
|
432
|
+
await sleep(FAST)
|
|
433
|
+
continue
|
|
434
|
+
}
|
|
274
435
|
const res = busy ? { items: [], paused: false } : await check()
|
|
275
436
|
const items = Array.isArray(res.items) ? res.items : []
|
|
276
437
|
if (firstCheck && Array.isArray(res.items)) {
|