openvisio-agent 0.18.15 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,7 +17,7 @@ That's it. Your selected runtime now has the team's tools (channels, tickets, do
17
17
 
18
18
  ## What it does
19
19
 
20
- `openvisio-agent` is a tiny, dependency-free CLI. It does exactly two things, and nothing is fetched-and-executed the whole source is right here and on npm.
20
+ `openvisio-agent` is a local CLI and autonomy harness. Nothing is fetched-and-executed outside its declared npm dependencies; the source is here and on npm.
21
21
 
22
22
  ### `connect <ovs_code> --host <url>`
23
23
 
@@ -50,11 +50,13 @@ npx -y openvisio-agent@latest connect --backend https://api.your-org.example/dev
50
50
  - `--ws <wss-url>` — the org's API-Gateway WebSocket base (the same value the frontend uses as `NEXT_PUBLIC_BACKEND_WS_URL`).
51
51
  - `--mcp-url <url>` — registers the `openvisio-team` MCP (authed with the agent header pair) so the agent has tools to **act** on the events.
52
52
 
53
- Then `watch --name ada` auto-detects the backend agent and runs a WebSocket loop instead of polling: it connects with `?api_key=&identifier=`, keeps the connection warm with keepalives, reconnects with backoff, and queues each accepted assignment or mention independently. A work cycle can contain many assistant, tool, and message actions; emitting a progress message does not end it. Needs **Node 21** for the built-in WebSocket (Node 20: run with `--experimental-websocket`).
53
+ Then `watch --name ada` auto-detects the backend agent and runs a WebSocket loop instead of polling: it connects with `?api_key=&identifier=`, keeps the connection warm with keepalives, reconnects with backoff, and queues each accepted assignment or mention independently. A work cycle can contain many assistant, tool, and message actions; emitting a progress message does not end it. The Mastra runtime requires **Node 22.13**.
54
54
 
55
55
  ### `watch --name <agent>`
56
56
 
57
- Runs the **autonomy loop** — the agent replies to @mentions and picks up tickets on its own. It cheaply polls an inbox endpoint (no model spend when idle) and pokes a single warm Claude Code session only when something new arrives.
57
+ Runs the **autonomy loop** — the agent replies to @mentions and picks up tickets on its own. It cheaply polls an inbox endpoint (no model spend when idle) and starts isolated runtime sessions only when something new arrives.
58
+
59
+ Codex and OpenCode cycles run through Mastra's ACP harness. Codex uses the packaged `codex-acp` adapter and reuses the machine's existing ChatGPT/Codex login; OpenCode uses its native `opencode acp` server. Each accepted ticket gets its own ACP session and worktree. Mastra Memory stores ticket/thread context in local libSQL under `~/.openvisio/`, while a compact JSON ledger retains only exact replay, cancellation, and delivery keys.
58
60
 
59
61
  Backend/BYO watchers reconcile immediately whenever the process starts or the WebSocket connects. A direct MCP session discovers and caches the backend's actual `tools/list` response, then uses available tools such as `list_agents`, `list_projects`, `list_tasks`, `list_task_types`, and `list_activity` to recover assigned tasks and recent mention activity missed while offline. Optional actions such as ticket comments are used only when advertised; their absence cannot strand a completed ticket in a retry loop. The same zero-model check runs every five minutes as a safety net; a model starts only when pending work exists.
60
62
 
@@ -62,7 +64,7 @@ The backend MCP may be stateful or stateless. A successful initialize response w
62
64
 
63
65
  Codex BYO agents follow the repository's normative runtime specification in `docs/CODEX_BYO_AGENT_SPEC.md`: one WebSocket identity, independent Sol reply/work lanes, authoritative `get_ticket` verification for assignments, REST-backed in-app activity, persistent replay suppression, and runtime evidence gates before completion. Maintainers must run `npm run certify` before publishing.
64
66
 
65
- An `agent:mention` event only wakes the watcher; it does not grant ownership of the conversation. The actual source message is checked before any model starts. Messages redirected to another agent and unaddressed agent chatter stay silent, while a direct stand-down cancels queued/running work for that thread. Accepted coding work uses the activity indicator instead of a generic pickup message. Claude and OpenCode may add one concrete progress update after work begins, but must continue and post a distinct verified result or blocker afterward; Codex keeps cancellation-safe delivery watcher-owned and renders the verified final answer once.
67
+ An `agent:mention` event only wakes the watcher; it does not grant ownership of the conversation. The actual source message is checked before any model starts. Messages redirected to another agent and unaddressed agent chatter stay silent, while a direct stand-down cancels queued/running work for that thread. The watcher does not broadcast working, thinking, or typing presence updates. Claude and OpenCode may add one concrete progress update after work begins, but must continue and post a distinct verified result or blocker afterward; Codex keeps cancellation-safe delivery watcher-owned and renders the verified final answer once.
66
68
 
67
69
  Ticket references follow the board UI: BYO agents use the project-scoped slug, such as `OVS-57`, in messages, comments, PR descriptions, blockers, and results. Numeric `project_id` and `ticket_id` values remain internal MCP arguments and are never used as human-facing ticket names. If an older backend omits the slug, the agent uses the ticket title rather than inventing one.
68
70
 
@@ -75,7 +77,7 @@ openvisio-agent watch --name ada --workdir ~/repo # allow REAL work on a git br
75
77
  openvisio-agent stop --name ada # stop service + every ada watcher
76
78
  ```
77
79
 
78
- The work lane uses the configured workspace and an `agent/*` branch. It reuses local clones and is instructed to preserve dirty/staged work, create unique branches, use separate worktrees for shared checkouts, and stage only task-owned changes. The independent reply lane receives a chat charter and no coding workspace. Codex publishes local branches through the constrained helper, then opens a PR with `gh pr create`; linked-codebase MCP operations are a fallback when the repository cannot be obtained locally.
80
+ The watcher can run up to three assigned coding tasks concurrently. Each ticket gets an isolated model runtime and ticket-specific worktree; tasks sharing the same mutable workspace remain serialized to prevent branch and file conflicts. Chat replies stay serialized, and cancellation targets only the matching ticket or source thread. The work pool reuses local clones and is instructed to preserve dirty/staged work, create unique branches, and stage only task-owned changes. The independent reply lane receives a chat charter and no coding workspace. Codex publishes local branches through the constrained helper, then opens a PR with `gh pr create`; linked-codebase MCP operations are a fallback when the repository cannot be obtained locally.
79
81
 
80
82
  Publishing a local branch uses an explicit, one-time authorization per repository:
81
83
 
@@ -86,7 +88,7 @@ openvisio-agent authorize-pr-push
86
88
 
87
89
  That command installs a narrow Codex rule for `openvisio-agent push-pr-branch` and records the repository's exact root and `origin`. The helper accepts no arguments and can only push `HEAD` to the same `agent/*` branch on that authorized origin. It disables repository hooks and rejects main/master, other branch namespaces, changed remotes, force pushes, local/file remotes, and credential-bearing URLs. Revoke it from the repository with `openvisio-agent revoke-pr-push`.
88
90
 
89
- Assignments use independent FIFO entries with stable-key duplicate suppression. Backlog recovery uses the same ticket verification path as WebSocket events, and each queued ticket is checked again before a model starts. MCP calls have a 20-second deadline including response bodies; tool discovery is cached and paginated. Activity requests are limited to one in flight per channel. Cancellation waits for process closure, with POSIX process-group termination for tool children. Credentials, replay state, and memory use atomic file replacement.
91
+ Assignments use independent FIFO entries with stable-key duplicate suppression and bounded, worktree-aware concurrency. Backlog recovery uses the same ticket verification path as WebSocket events, and each queued ticket is checked again before a model starts. MCP calls have a 20-second deadline including response bodies; tool discovery is cached and paginated. Codex MCP failures retain a bounded, credential-redacted reason in the watcher log. Cancellation targets only its ACP session. Credentials and replay state use atomic file replacement; Mastra persists contextual memory in libSQL.
90
92
 
91
93
  See `docs/BYO_SYSTEM_AUDIT.md` in the repository for the audit results and remaining live-validation limits. Local certification does not certify a deployed agent's behavior.
92
94
 
@@ -103,7 +105,7 @@ Do not chase auto-changing watcher PIDs. `openvisio-agent stop --name <agent>` u
103
105
 
104
106
  ## Requirements
105
107
 
106
- - Node.js ≥ 18
108
+ - Node.js ≥ 22.13
107
109
  - Claude Code, Codex (`@openai/codex`), or OpenCode (the selected CLI is auto-installed if missing)
108
110
 
109
111
  ## Getting a setup code
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.18.15",
3
+ "version": "0.19.0",
4
4
  "description": "Connect Claude Code, Codex, or OpenCode to an OpenVisio team — MCP tools + optional autonomy — in one command.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,7 +17,7 @@
17
17
  "README.md"
18
18
  ],
19
19
  "engines": {
20
- "node": ">=18"
20
+ "node": ">=22.13"
21
21
  },
22
22
  "keywords": [
23
23
  "openvisio",
@@ -34,5 +34,11 @@
34
34
  "url": "git+https://github.com/syntaxPriest/OpenVisio.git",
35
35
  "directory": "packages/openvisio-agent"
36
36
  },
37
- "dependencies": {}
37
+ "dependencies": {
38
+ "@agentclientprotocol/codex-acp": "1.10.0",
39
+ "@mastra/acp": "0.4.1",
40
+ "@mastra/core": "1.64.0",
41
+ "@mastra/libsql": "1.22.3",
42
+ "@mastra/memory": "1.28.2"
43
+ }
38
44
  }
@@ -31,6 +31,7 @@ const prPush = readFileSync(join(root, 'src', 'pr-push.mjs'), 'utf8')
31
31
  const cli = readFileSync(join(root, 'bin', 'cli.mjs'), 'utf8')
32
32
  const websocket = readFileSync(join(root, 'src', 'ws.mjs'), 'utf8')
33
33
  const cycleQueue = readFileSync(join(root, 'src', 'cycle-queue.mjs'), 'utf8')
34
+ const mastraHarness = readFileSync(join(root, 'src', 'mastra-harness.mjs'), 'utf8')
34
35
  const activityHook = readFileSync(join(repo, 'frontend', 'hooks', 'useAgentActivity.ts'), 'utf8')
35
36
  const taskHook = readFileSync(join(repo, 'frontend', 'hooks', 'useBackendTasks.ts'), 'utf8')
36
37
  const liveTasks = readFileSync(join(repo, 'frontend', 'lib', 'collab', 'liveTasks.ts'), 'utf8')
@@ -56,7 +57,7 @@ const assertions = [
56
57
  ['reconciled mentions reuse the guarded websocket delivery path', watcher.includes("onEvent('agent:mention'") && watcher.includes('_mentionAlreadyMarked: true')],
57
58
  ['conversation wake events are recipient-filtered before model start', watcher.includes('classifyConversationTarget(msg, [...selfAliases])') && events.includes("reason: 'explicit-other-recipient'") && events.includes("reason: 'other-agent-chatter'")],
58
59
  ['coding mentions require an action and concrete repository target', watcher.includes('conversationNeedsCode(text)') && events.includes('const action =') && events.includes('const target =')],
59
- ['stand-down and redirects cancel source-thread work across runtimes', watcher.includes('cancelThread(cid, threadRoot') && watcher.includes('activeDelivery') && watcher.includes('cancelCurrent') && watcher.includes("subtype === 'canceled'")],
60
+ ['stand-down and redirects cancel only source-thread workers', watcher.includes('cancelThread(cid, threadRoot') && watcher.includes('item.control.cancelled = true') && watcher.includes('item.control.runner?.cancelCurrent') && watcher.includes("subtype === 'canceled'")],
60
61
  ['thread cancellation is rechecked immediately before watcher delivery', watcher.includes('const newlyCancelled = suppressCancelled()') && watcher.includes('if (newlyCancelled) return newlyCancelled')],
61
62
  ['generic coding pickup messages are absent', !watcher.includes("I've picked this up and will return here with the verified result")],
62
63
  ['human-facing ticket references require slugs, never database ids', watcher.includes('TICKET SLUGS, NEVER DATABASE IDS') && watcher.includes('ticketDisplaySlug(task)') && events.includes('export function ticketDisplaySlug') && !events.includes('I finished ticket #${task.id}')],
@@ -66,17 +67,18 @@ const assertions = [
66
67
  ['Codex discovers deferred OpenVisio actions before claiming they are missing', watcher.includes('Codex may defer MCP actions') && watcher.includes('use tool_search to find that exact action') && watcher.includes('initial-list miss is NOT evidence that the server is disconnected')],
67
68
  ['Codex authentication is injected outside the model', codexConfig.includes('OPENVISIO_CODEX_API_KEY') && codexProxy.includes('toolWithoutCredentialInputs') && codexProxy.includes('client.callTool') && watcher.includes('local OpenVisio MCP bridge injects authentication outside the model')],
68
69
  ['reply delivery keys survive reconnects', watcher.includes('deliveredReplies: [...deliveredReplies]') && watcher.includes('deliveredReplies.has(deliveryKey)')],
69
- ['tickets and guarded replies use the behavior-tested serial queue', watcher.includes('createCycleQueue({') && watcher.includes('queues[laneName].enqueue') && cycleQueue.includes('const pending = new Map()')],
70
+ ['tickets and guarded replies use the behavior-tested bounded queue', watcher.includes('createCycleQueue({') && watcher.includes('queues[laneName].enqueue') && cycleQueue.includes('const pending = new Map()') && cycleQueue.includes('activeGroups')],
70
71
  ['queued assignments are checked again before model execution', watcher.includes('queued ticket no longer actionable; skipped before model start')],
71
72
  ['feature titles cannot be mistaken for coordination commands', watcher.includes('taskIsCoordinationOnly(taskText)') && events.includes('Uploading files as messages') && events.includes('explicitCoordination')],
72
- ['reply runner has no coding workspace or coding charter', watcher.includes("workdir: '', systemPrompt: CHAT_CHARTER") && opencodeConfig.includes("'*': 'deny'")],
73
+ ['reply runner has no coding workspace or coding charter', watcher.includes("workdir: ''") && watcher.includes("systemPrompt: CHAT_CHARTER + '\\n\\n' + credNote") && opencodeConfig.includes("'*': 'deny'")],
73
74
  ['MCP requests have a deadline including response bodies', mcpHttp.includes('controller.abort()') && mcpHttp.includes('const body = await res.text()')],
74
- ['BYO memory uses real ticket and thread identities', watcher.includes('createByoMemoryGraph') && watcher.includes('memory.context(memoryRefs)') && memory.includes('sameRef(r.projectId, refs.projectId)') && memory.includes('sameRef(r.threadId, refs.threadId)')],
75
- ['two internal lanes exist', watcher.includes('work: createCycleRunner') && watcher.includes('reply: createCycleRunner')],
76
- ['work and reply activity targets are isolated', watcher.includes("laneStatusTargets = { work: new Set(), reply: new Set() }") && watcher.includes("emitLaneStatus('work', 'typing')") && watcher.includes("emitLaneStatus('reply', 'typing')")],
75
+ ['Mastra memory uses real ticket and thread identities', watcher.includes('createMastraMemory') && watcher.includes('await memory.context(memoryRefs)') && memory.includes('new Memory({ storage') && memory.includes('new LibSQLStore') && memory.includes('ticket:${refs.projectId}:${refs.ticketId}') && memory.includes('channel:${refs.channelId}:thread:')],
76
+ ['Codex and OpenCode use isolated Mastra ACP sessions', watcher.includes('createMastraAcpRunner') && mastraHarness.includes('new AcpAgentClass') && mastraHarness.includes('persistSession: false') && mastraHarness.includes("runtime: 'mastra-acp'")],
77
+ ['one watcher owns isolated concurrent work and serial reply runtimes', watcher.includes('MAX_CONCURRENT_WORKERS = 3') && watcher.includes('createWorkRunner(item.control, item.workdir)') && watcher.includes('const replyRunner = createCycleRunner')],
78
+ ['workers serialize shared worktrees while independent worktrees run concurrently', watcher.includes('groupKey: (item) => item.workdir || workdir') && cycleQueue.includes('active.size < concurrency') && cycleQueue.includes('activeGroups.has(entry.group)')],
79
+ ['work and reply cancellation targets are isolated', watcher.includes('item.control.cancelled = true') && watcher.includes('item.control.runner?.cancelCurrent')],
77
80
  ['assigned task activity resolves a project channel', watcher.includes("callMcpTool('list_channels'") && watcher.includes('projectStatusChannel(projectId)')],
78
- ['working heartbeat is battery-conscious', watcher.includes("emitLaneStatus(laneName, 'working'), 20_000")],
79
- ['activity uses REST endpoint', watcher.includes('agentStateRequest(')],
81
+ ['presence notification requests are disabled', watcher.includes('const sendStatus = () => {}') && !watcher.includes('agentStateRequest(') && !watcher.includes("setInterval(() => emitStatusTargets")],
80
82
  ['websocket client cannot emit legacy agent_status', !websocket.includes('agent_status')],
81
83
  ['frontend consumes thinking event', activityHook.includes("'channel:agent:thinking'")],
82
84
  ['frontend consumes working event', activityHook.includes("'channel:agent:working'")],
@@ -101,6 +103,7 @@ const assertions = [
101
103
  ['OpenCode tool failures retain sanitized diagnostics', events.includes('toolError: toolError.replace') && watcher.includes("opencode tool '") && watcher.includes("split(redactKey).join('[redacted]')")],
102
104
  ['all runtime acknowledgements cannot satisfy coding completion', watcher.includes('missingRuntimeWorkEvidence(result') && watcher.includes('claudeEventEvidence(o, turnToolUses)') && watcher.includes('releaseTaskForRetry(activeTaskRef, prompt)')],
103
105
  ['Codex prose cannot masquerade as repository evidence', watcher.includes('codexEventEvidence(event)') && events.includes("item.type === 'agent_message'") && events.includes("event.type === 'item.completed'")],
106
+ ['Codex MCP failures retain sanitized per-call diagnostics', events.includes('rawToolError = item.error') && watcher.includes('mcpErrorDetails.set(evidence.mcpTool, safe)') && watcher.includes("join('[redacted]')")],
104
107
  ['coding recovery retains source context and requires a final result', watcher.includes('ORIGINAL REQUEST AND ROUTING CONTEXT') && events.includes('resultMessageRequired && !result?.didResultMessage') && events.includes("outputText: second?.outputText || first?.outputText || ''")],
105
108
  ['backend MCP accepts stateless initialize responses', mcpHttp.includes("mode = () => !initialized ? 'uninitialized' : sessionId ? 'stateful' : 'stateless'") && !watcher.includes('MCP initialize returned no session id')],
106
109
  ['MCP initialize is shared across concurrent startup probes', mcpHttp.includes('if (initializePromise) return initializePromise')],
@@ -21,6 +21,9 @@ export function runCodexMcpProxy() {
21
21
  const url = process.env.OPENVISIO_CODEX_MCP_URL
22
22
  const apiKey = process.env.OPENVISIO_CODEX_API_KEY
23
23
  const identifier = process.env.OPENVISIO_CODEX_IDENTIFIER
24
+ let disabledTools = []
25
+ try { disabledTools = JSON.parse(process.env.OPENVISIO_CODEX_DISABLED_TOOLS || '[]') } catch { /* no disabled tools */ }
26
+ const disabled = new Set(Array.isArray(disabledTools) ? disabledTools.map(String) : [])
24
27
  if (!url || !apiKey || !identifier) throw new Error('OpenVisio Codex MCP bridge is missing its watcher environment.')
25
28
  const client = createMcpHttpClient({ url, apiKey, identifier, clientVersion: 'openvisio-agent-codex-proxy' })
26
29
  const reply = (id, result) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n')
@@ -52,8 +55,9 @@ export function runCodexMcpProxy() {
52
55
  reply(id, {})
53
56
  } else if (request.method === 'tools/list') {
54
57
  const tools = await client.listTools()
55
- reply(id, { tools: tools.map(toolWithoutCredentialInputs) })
58
+ reply(id, { tools: tools.filter((tool) => !disabled.has(String(tool.name))).map(toolWithoutCredentialInputs) })
56
59
  } else if (request.method === 'tools/call') {
60
+ if (disabled.has(String(request.params?.name || ''))) throw new Error('This tool is disabled for the current delivery lane.')
57
61
  reply(id, await client.callTool(request.params?.name, request.params?.arguments || {}))
58
62
  } else if (id != null) {
59
63
  process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found' } }) + '\n')
@@ -1,33 +1,45 @@
1
1
  // One immutable source per cycle. A Map provides FIFO ordering and constant-time
2
2
  // replay checks without merging ticket identities or starving older assignments.
3
- export function createCycleQueue({ run, onError = () => {} }) {
3
+ // Bounded concurrency is opt-in. `groupKey` keeps items sharing a repository or
4
+ // another mutable resource serialized while independent work can run in parallel.
5
+ export function createCycleQueue({ run, onError = () => {}, concurrency = 1, groupKey = () => '' }) {
6
+ if (!Number.isInteger(concurrency) || concurrency < 1) throw new Error('cycle queue concurrency must be a positive integer')
4
7
  const pending = new Map()
5
- let active = null
8
+ const active = new Map()
9
+ const activeGroups = new Set()
6
10
  let sequence = 0
7
11
 
8
- const pump = async () => {
9
- if (active || !pending.size) return
10
- const [key, entry] = pending.entries().next().value
11
- pending.delete(key)
12
- active = { key, ...entry }
13
- try { entry.resolve(await run(entry.item)) }
14
- catch (error) {
15
- try { onError(error, entry.item) } catch { /* logging cannot strand the queue */ }
16
- entry.resolve({ status: 'failed' })
17
- } finally {
18
- active = null
19
- void pump()
12
+ const pump = () => {
13
+ while (active.size < concurrency && pending.size) {
14
+ const available = [...pending].find(([, entry]) => !entry.group || !activeGroups.has(entry.group))
15
+ if (!available) return
16
+ const [key, entry] = available
17
+ pending.delete(key)
18
+ active.set(key, entry)
19
+ if (entry.group) activeGroups.add(entry.group)
20
+ void (async () => {
21
+ try { entry.resolve(await run(entry.item)) }
22
+ catch (error) {
23
+ try { onError(error, entry.item) } catch { /* logging cannot strand the queue */ }
24
+ entry.resolve({ status: 'failed' })
25
+ } finally {
26
+ active.delete(key)
27
+ if (entry.group) activeGroups.delete(entry.group)
28
+ pump()
29
+ }
30
+ })()
20
31
  }
21
32
  }
22
33
 
23
34
  return {
24
35
  enqueue(item, key = `cycle:${++sequence}`) {
25
- if (active?.key === key) return active.promise
36
+ if (active.has(key)) return active.get(key).promise
26
37
  if (pending.has(key)) return pending.get(key).promise
27
38
  let resolve
28
39
  const promise = new Promise((done) => { resolve = done })
29
- pending.set(key, { item, promise, resolve })
30
- void pump()
40
+ const group = String(groupKey(item) || '')
41
+ pending.set(key, { item, promise, resolve, group })
42
+ pump()
31
43
  return promise
32
44
  },
33
45
  cancel(predicate, cancelActive = () => {}) {
@@ -36,9 +48,10 @@ export function createCycleQueue({ run, onError = () => {} }) {
36
48
  pending.delete(key)
37
49
  entry.resolve({ status: 'canceled' })
38
50
  }
39
- if (active && predicate(active.item)) cancelActive(active.item)
51
+ for (const entry of active.values()) if (predicate(entry.item)) cancelActive(entry.item)
40
52
  },
41
- has(key) { return active?.key === key || pending.has(key) },
42
- get size() { return pending.size + (active ? 1 : 0) },
53
+ has(key) { return active.has(key) || pending.has(key) },
54
+ get size() { return pending.size + active.size },
55
+ get activeSize() { return active.size },
43
56
  }
44
57
  }
package/src/events.mjs CHANGED
@@ -189,10 +189,14 @@ export function codexEventEvidence(event) {
189
189
  const prefixed = /^(?:mcp__)?openvisio(?:-team|_team)(?:__|[_.:/-])(.+)$/i.exec(rawTool)
190
190
  const mcpTool = String(prefixed?.[1] ?? rawTool).replace(/[-.]/g, '_')
191
191
  const succeeded = completed && !failed
192
+ const resultText = item.result?.content?.find?.((part) => part?.type === 'text')?.text
193
+ const rawToolError = item.error ?? item.result?.error ?? resultText
194
+ const toolError = rawToolError == null ? '' : (typeof rawToolError === 'string' ? rawToolError : JSON.stringify(rawToolError))
192
195
  return {
193
196
  mcpTool,
194
197
  failed,
195
198
  completed: succeeded,
199
+ ...(failed && toolError ? { toolError: toolError.replace(/\s+/g, ' ').slice(0, 500) } : {}),
196
200
  didCode: succeeded && /^(?:create_codebase_branch|create_codebase_commit|write_codebase_file|create_pull_request)$/.test(mcpTool),
197
201
  didRepoMutation: succeeded && /^(?:create_codebase_branch|create_codebase_commit|write_codebase_file|create_pull_request)$/.test(mcpTool),
198
202
  didMcpTaskRead: succeeded && /^(?:get_ticket|list_tasks)$/.test(mcpTool),
@@ -0,0 +1,185 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { AcpAgent } from '@mastra/acp'
4
+ import { onPath } from './lib.mjs'
5
+
6
+ const MCP_TOOL_NAMES = [
7
+ 'list_agents', 'list_projects', 'list_tasks', 'list_task_types', 'get_ticket',
8
+ 'update_ticket', 'list_channels', 'list_message_thread', 'list_activity',
9
+ 'post_message', 'react_message', 'list_codebases', 'get_codebase',
10
+ 'codebase_tree', 'create_codebase_branch', 'create_codebase_commit',
11
+ 'create_pull_request',
12
+ ]
13
+
14
+ const clean = (value, max = 300) => String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, max)
15
+ const json = (value) => { try { return JSON.stringify(value) } catch { return String(value ?? '') } }
16
+
17
+ function commandFor(agent) {
18
+ if (agent === 'opencode') return { command: onPath('opencode') || 'opencode', args: ['acp'] }
19
+ if (agent === 'codex') {
20
+ const filename = process.platform === 'win32' ? 'codex-acp.cmd' : 'codex-acp'
21
+ return { command: fileURLToPath(new URL(`../node_modules/.bin/${filename}`, import.meta.url)), args: [] }
22
+ }
23
+ return null
24
+ }
25
+
26
+ const proxyPath = fileURLToPath(new URL('./codex-mcp-proxy.mjs', import.meta.url))
27
+
28
+ function repositorySnapshot(cwd) {
29
+ if (!cwd) return ''
30
+ const result = spawnSync('git', ['status', '--porcelain=v1', '--untracked-files=all'], {
31
+ cwd, encoding: 'utf8', timeout: 10_000,
32
+ })
33
+ return result.status === 0 ? String(result.stdout || '') : ''
34
+ }
35
+
36
+ function toolName(update) {
37
+ const haystack = `${update?.title || ''} ${json(update?.rawInput)}`
38
+ return MCP_TOOL_NAMES.find((name) => new RegExp(`(?:^|[^a-z0-9_])${name}(?:$|[^a-z0-9_])`, 'i').test(haystack)) || ''
39
+ }
40
+
41
+ function contentText(content) {
42
+ if (!content || typeof content !== 'object') return ''
43
+ if (content.type === 'text') return String(content.text || '')
44
+ if (content.type === 'content') return contentText(content.content)
45
+ return ''
46
+ }
47
+
48
+ export function createMastraAcpRunner({
49
+ agent, mcpUrl, mcpHeaders = {}, workdir, canCode = !!workdir, maxCycleMs,
50
+ log = () => {}, debug = false, model, onTool, systemPrompt = '', AcpAgentClass = AcpAgent,
51
+ }) {
52
+ const runtime = commandFor(agent)
53
+ if (!runtime) return null
54
+ let active = null
55
+ const defaultCwd = workdir || process.cwd()
56
+
57
+ async function runCycle(prompt, cycleModel, cycleOptions = {}) {
58
+ const cycleCwd = cycleOptions.workdir || defaultCwd
59
+ const controller = new AbortController()
60
+ let timedOut = false
61
+ const before = repositorySnapshot(canCode ? cycleCwd : '')
62
+ const calls = new Set()
63
+ const errors = new Map()
64
+ const tools = new Map()
65
+ let outputText = ''
66
+ let didCode = false
67
+ let didRepoMutation = false
68
+ let didMessage = false
69
+ let didChannelMessage = false
70
+ let didResultMessage = false
71
+ let didMcpTaskRead = false
72
+ let didMcpTaskUpdate = false
73
+
74
+ const headers = Object.entries(mcpHeaders).filter(([, value]) => value != null && value !== '').map(([name, value]) => ({ name, value: String(value) }))
75
+ const disabledMcpTools = Array.isArray(cycleOptions.disabledMcpTools) ? cycleOptions.disabledMcpTools.filter(Boolean) : []
76
+ const mcpServers = !mcpUrl ? [] : agent === 'codex' ? [{
77
+ name: 'openvisio-team', command: process.execPath, args: [proxyPath], env: [
78
+ { name: 'OPENVISIO_CODEX_MCP_URL', value: mcpUrl },
79
+ { name: 'OPENVISIO_CODEX_API_KEY', value: String(mcpHeaders['x-agent-api-key'] || '') },
80
+ { name: 'OPENVISIO_CODEX_IDENTIFIER', value: String(mcpHeaders['x-agent-identifier'] || '') },
81
+ { name: 'OPENVISIO_CODEX_DISABLED_TOOLS', value: JSON.stringify(disabledMcpTools) },
82
+ ],
83
+ }] : [{ type: 'http', name: 'openvisio-team', url: mcpUrl, headers }]
84
+ const acp = new AcpAgentClass({
85
+ id: `openvisio-${agent}-${Date.now()}`,
86
+ name: `OpenVisio ${agent}`,
87
+ description: 'An isolated OpenVisio coding-agent run.',
88
+ command: runtime.command,
89
+ args: runtime.args,
90
+ cwd: cycleCwd,
91
+ env: agent === 'codex' ? {
92
+ INITIAL_AGENT_MODE: canCode ? 'agent' : 'read-only',
93
+ NO_BROWSER: '1',
94
+ } : {},
95
+ session: {
96
+ cwd: cycleCwd,
97
+ mcpServers,
98
+ },
99
+ persistSession: false,
100
+ ...(cycleModel || model ? { model: cycleModel || model } : {}),
101
+ onPermissionRequest: async ({ options }) => {
102
+ const kind = canCode ? 'allow_once' : 'reject_once'
103
+ const selected = options.find((option) => option.kind === kind) || options.find((option) => option.kind.startsWith(canCode ? 'allow' : 'reject'))
104
+ return selected ? { outcome: { outcome: 'selected', optionId: selected.optionId } } : { outcome: { outcome: 'cancelled' } }
105
+ },
106
+ // codex-acp publishes account-status extension notifications. They are
107
+ // useful to interactive clients but should be silent in a background
108
+ // watcher, and the stock Mastra ACP client intentionally knows only ACP.
109
+ createClient: agent === 'codex' ? (client) => new Proxy(client, {
110
+ get(target, property, receiver) {
111
+ if (property === 'extNotification') return async () => {}
112
+ return Reflect.get(target, property, receiver)
113
+ },
114
+ }) : undefined,
115
+ })
116
+ active = { acp, controller }
117
+ const timer = setTimeout(() => { timedOut = true; controller.abort() }, maxCycleMs)
118
+ log(`running ${agent} cycle via Mastra ACP…${cycleModel || model ? ` [${cycleModel || model}]` : ''}`)
119
+ try {
120
+ const fullPrompt = systemPrompt ? `${systemPrompt}\n\n${prompt}` : prompt
121
+ for await (const event of acp.connection.promptStream(fullPrompt, controller.signal)) {
122
+ if (event.type === 'text') { outputText += event.text; continue }
123
+ const update = event.update || {}
124
+ if (update.sessionUpdate === 'agent_message_chunk') {
125
+ outputText += contentText(update.content)
126
+ continue
127
+ }
128
+ if (!['tool_call', 'tool_call_update'].includes(update.sessionUpdate)) continue
129
+ const prior = tools.get(update.toolCallId) || {}
130
+ const merged = { ...prior, ...update }
131
+ tools.set(update.toolCallId, merged)
132
+ const name = toolName(merged)
133
+ const label = name || clean(merged.title, 100) || 'tool'
134
+ try { onTool?.(label) } catch { /* status is best-effort */ }
135
+ if (debug && update.sessionUpdate === 'tool_call') log(` → tool ${label}`)
136
+ if (name) {
137
+ calls.add(name)
138
+ if (merged.status === 'failed') errors.set(name, clean(json(merged.rawOutput) || merged.title))
139
+ else if (merged.status === 'completed') errors.delete(name)
140
+ didMessage ||= name === 'post_message'
141
+ didChannelMessage ||= name === 'post_message'
142
+ didMcpTaskRead ||= ['list_tasks', 'list_task_types', 'get_ticket'].includes(name)
143
+ didMcpTaskUpdate ||= name === 'update_ticket'
144
+ }
145
+ const title = clean(merged.title, 200).toLowerCase()
146
+ const looksLikeMutation = merged.kind === 'edit' || /\b(edit|write|patch|create|delete|commit)\b/.test(title)
147
+ didCode ||= ['edit', 'execute'].includes(merged.kind) || looksLikeMutation
148
+ didRepoMutation ||= looksLikeMutation
149
+ }
150
+ const after = repositorySnapshot(canCode ? cycleCwd : '')
151
+ if (before !== after) { didCode = true; didRepoMutation = true }
152
+ didResultMessage = didChannelMessage && didRepoMutation
153
+ log(`${agent} cycle done via Mastra ACP (ok)`)
154
+ return {
155
+ type: 'result', subtype: 'ok', runtime: 'mastra-acp', outputText: outputText.trim(),
156
+ mcpCalls: [...calls], mcpErrors: [...errors.keys()], mcpErrorDetails: Object.fromEntries(errors),
157
+ didCode, didRepoMutation, didMessage, didChannelMessage, didResultMessage,
158
+ didMcpTaskRead, didMcpTaskUpdate,
159
+ }
160
+ } catch (error) {
161
+ const canceled = controller.signal.aborted
162
+ const subtype = timedOut ? 'timeout' : canceled ? 'canceled' : 'error'
163
+ log(`${agent} cycle done via Mastra ACP (${subtype}${!canceled && error?.message ? ': ' + clean(error.message) : ''})`)
164
+ return {
165
+ type: 'result', subtype, runtime: 'mastra-acp', outputText: outputText.trim(),
166
+ mcpCalls: [...calls], mcpErrors: [...errors.keys()], mcpErrorDetails: Object.fromEntries(errors),
167
+ didCode, didRepoMutation, didMessage, didChannelMessage, didResultMessage,
168
+ didMcpTaskRead, didMcpTaskUpdate,
169
+ }
170
+ } finally {
171
+ clearTimeout(timer)
172
+ acp.connection.disconnect()
173
+ if (active?.acp === acp) active = null
174
+ }
175
+ }
176
+
177
+ const cancelCurrent = async () => {
178
+ if (!active) return
179
+ active.controller.abort()
180
+ try { await active.acp.connection.cancel() } catch { /* already stopped */ }
181
+ }
182
+
183
+ log(`${agent} Mastra ACP runner ready${canCode ? ` [CODE workspace ${defaultCwd}]` : ' [CHAT-ONLY]'}`)
184
+ return { runCycle, canCode, cancelCurrent, harness: 'mastra-acp' }
185
+ }
package/src/memory.mjs CHANGED
@@ -1,4 +1,7 @@
1
1
  import { readFileSync } from 'node:fs'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { Memory } from '@mastra/memory'
4
+ import { LibSQLStore } from '@mastra/libsql'
2
5
  import { writeJson } from './lib.mjs'
3
6
 
4
7
  const clean = (value, max = 320) => String(value || '').replace(/\s+/g, ' ').trim().slice(0, max)
@@ -79,3 +82,70 @@ export function createByoMemoryGraph({ path, maxNodes = 1000, now = () => Date.n
79
82
 
80
83
  return { remember, connect, recall, context, has, persist }
81
84
  }
85
+
86
+ const memoryThreadId = (refs = {}) => {
87
+ if (refs.projectId != null && refs.ticketId != null) return `ticket:${refs.projectId}:${refs.ticketId}`
88
+ if (refs.channelId != null) return `channel:${refs.channelId}:thread:${refs.threadId ?? 'root'}`
89
+ return ''
90
+ }
91
+
92
+ const messageText = (message) => {
93
+ const parts = message?.content?.parts
94
+ if (!Array.isArray(parts)) return clean(message?.content?.content, 2000)
95
+ return clean(parts.filter((part) => part?.type === 'text').map((part) => part.text).join(' '), 2000)
96
+ }
97
+
98
+ // Mastra owns durable task/thread context. The JSON graph remains a deliberately
99
+ // small coordination ledger for exact replay and delivery keys; it is not used as
100
+ // the primary transcript once the libSQL thread has messages.
101
+ export function createMastraMemory({ ledgerPath, databasePath, resourceId, maxNodes = 1000, now = () => Date.now() }) {
102
+ const ledger = createByoMemoryGraph({ path: ledgerPath, maxNodes, now })
103
+ const storage = new LibSQLStore({ id: `openvisio-memory-${resourceId}`, url: `file:${databasePath}` })
104
+ const memory = new Memory({ storage, options: { lastMessages: 12, semanticRecall: false, workingMemory: { enabled: false } } })
105
+ const threads = new Map()
106
+ let pending = Promise.resolve()
107
+
108
+ const ensureThread = (threadId) => {
109
+ if (!threads.has(threadId)) {
110
+ threads.set(threadId, (async () => {
111
+ const existing = await memory.getThreadById({ threadId, resourceId })
112
+ return existing || memory.createThread({ threadId, resourceId, title: threadId })
113
+ })())
114
+ }
115
+ return threads.get(threadId)
116
+ }
117
+
118
+ const queueMessage = (entry) => {
119
+ const threadId = memoryThreadId(entry?.refs)
120
+ if (!threadId) return
121
+ pending = pending.then(async () => {
122
+ await ensureThread(threadId)
123
+ const text = `${entry.kind || 'event'} ${entry.state || 'observed'}: ${entry.summary || entry.key}`
124
+ await memory.saveMessages({ messages: [{
125
+ id: randomUUID(), role: 'assistant', createdAt: new Date(now()), threadId, resourceId,
126
+ content: { format: 2, parts: [{ type: 'text', text }], metadata: { openvisioKey: entry.key } },
127
+ }] })
128
+ }).catch(() => { /* memory is best-effort; live backend checks stay authoritative */ })
129
+ }
130
+
131
+ const remember = (entry) => {
132
+ const node = ledger.remember(entry)
133
+ if (node) queueMessage(node)
134
+ return node
135
+ }
136
+
137
+ const context = async (refs = {}, limit = 8) => {
138
+ const threadId = memoryThreadId(refs)
139
+ if (!threadId) return ''
140
+ await pending
141
+ try {
142
+ const recalled = await memory.recall({ threadId, resourceId, perPage: limit })
143
+ const items = recalled.messages.map(messageText).filter(Boolean).slice(-limit)
144
+ if (items.length) return ['RELEVANT VERIFIED MEMORY (do not repeat completed/delivered actions):', ...items.map((item) => `- ${item}`)].join('\n')
145
+ } catch { /* fall through to the migration-safe ledger */ }
146
+ return ledger.context(refs, limit)
147
+ }
148
+
149
+ const settled = async () => { await pending; await memory.settled() }
150
+ return { remember, connect: ledger.connect, recall: ledger.recall, context, has: ledger.has, persist: ledger.persist, settled, provider: 'mastra-libsql' }
151
+ }
package/src/watch.mjs CHANGED
@@ -11,14 +11,15 @@ import { join, dirname } from 'node:path'
11
11
  import { fileURLToPath } from 'node:url'
12
12
  import { OV_DIR, DEFAULT_WORKSPACE, readConfig, writeJson, configPath, onPath, fail, ok, info, slugify, stripSlash, chmodSafe } from './lib.mjs'
13
13
  import { connectAgentWs, assertWebSocket } from './ws.mjs'
14
- import { agentAddedByName, agentStateRequest, buildTaskCompletionReport, claudeEventEvidence, classifyConversationTarget, codexEventEvidence, codexPolicyBlock, combineRuntimeWorkEvidence, conversationNeedsCode, failedTaskRevisionIsCurrent, mentionDedupeKeys, missingRuntimeWorkEvidence, normalizeRenderedMessageText, opencodeEventEvidence, renderedAgentMessages, shouldSuppressCodexDiagnostic, taskAgentId, taskFromEvent, taskIsAwaitingReview, taskIsCompleted, taskIsCoordinationOnly, taskRevision, ticketDisplaySlug } from './events.mjs'
15
- import { createByoMemoryGraph } from './memory.mjs'
14
+ import { agentAddedByName, buildTaskCompletionReport, claudeEventEvidence, classifyConversationTarget, codexEventEvidence, codexPolicyBlock, combineRuntimeWorkEvidence, conversationNeedsCode, failedTaskRevisionIsCurrent, mentionDedupeKeys, missingRuntimeWorkEvidence, normalizeRenderedMessageText, opencodeEventEvidence, renderedAgentMessages, shouldSuppressCodexDiagnostic, taskAgentId, taskFromEvent, taskIsAwaitingReview, taskIsCompleted, taskIsCoordinationOnly, taskRevision, ticketDisplaySlug } from './events.mjs'
15
+ import { createMastraMemory } from './memory.mjs'
16
16
  import { repositoryHasPrPushAuthorization } from './pr-push.mjs'
17
17
  import { createMcpHttpClient } from './mcp-http.mjs'
18
18
  import { buildOpencodeConfig, opencodeRuntimeLayout } from './opencode-config.mjs'
19
19
  import { buildCodexMcpOverride } from './codex-config.mjs'
20
20
  import { createCycleQueue } from './cycle-queue.mjs'
21
21
  import { modelProcessOptions, stopModelProcess } from './process-lifecycle.mjs'
22
+ import { createMastraAcpRunner } from './mastra-harness.mjs'
22
23
 
23
24
  function findTicketWorktree(workspaceRoot, ticketId) {
24
25
  if (!workspaceRoot || ticketId == null) return ''
@@ -473,7 +474,7 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
473
474
  let terminationResult = null
474
475
  let cancel = null
475
476
  let policyBlock = null
476
- const mcpCalls = new Set(), mcpErrors = new Set()
477
+ const mcpCalls = new Set(), mcpErrors = new Set(), mcpErrorDetails = new Map()
477
478
  let didMcpTaskRead = false, didMcpTaskUpdate = false
478
479
  const finish = (o) => {
479
480
  if (done) return
@@ -481,8 +482,9 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
481
482
  if (cancelActive === cancel) cancelActive = null
482
483
  clearTimeout(timer)
483
484
  const calls = [...mcpCalls]
484
- log('codex MCP calls: ' + (calls.length ? calls.join(', ') : 'none') + (mcpErrors.size ? ' (failed: ' + [...mcpErrors].join(', ') + ')' : ''))
485
- resolve({ ...o, didCode, didRepoMutation, didMessage, didChannelMessage, didResultMessage, didMcpTaskRead, didMcpTaskUpdate, mcpCalls: calls, mcpErrors: [...mcpErrors], outputText, policyBlock })
485
+ const failedSummary = [...mcpErrors].map((name) => `${name}${mcpErrorDetails.get(name) ? ': ' + mcpErrorDetails.get(name) : ''}`).join('; ')
486
+ log('codex MCP calls: ' + (calls.length ? calls.join(', ') : 'none') + (failedSummary ? ' (failed: ' + failedSummary + ')' : ''))
487
+ resolve({ ...o, didCode, didRepoMutation, didMessage, didChannelMessage, didResultMessage, didMcpTaskRead, didMcpTaskUpdate, mcpCalls: calls, mcpErrors: [...mcpErrors], mcpErrorDetails: Object.fromEntries(mcpErrorDetails), outputText, policyBlock })
486
488
  }
487
489
  const terminate = (subtype) => {
488
490
  terminationResult ||= { type: 'result', subtype }
@@ -518,8 +520,18 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
518
520
  if (evidence.mcpTool) {
519
521
  mcpCalls.add(evidence.mcpTool)
520
522
  try { onTool && onTool(evidence.mcpTool) } catch { /* activity is best-effort */ }
521
- if (evidence.failed) mcpErrors.add(evidence.mcpTool)
522
- else if (evidence.completed) mcpErrors.delete(evidence.mcpTool)
523
+ if (evidence.failed) {
524
+ mcpErrors.add(evidence.mcpTool)
525
+ if (evidence.toolError) {
526
+ const secret = String(mcpHeaders?.['x-agent-api-key'] || '')
527
+ const detail = String(evidence.toolError)
528
+ const safe = (secret ? detail.split(secret).join('[redacted]') : detail).slice(0, 300)
529
+ mcpErrorDetails.set(evidence.mcpTool, safe)
530
+ }
531
+ } else if (evidence.completed) {
532
+ mcpErrors.delete(evidence.mcpTool)
533
+ mcpErrorDetails.delete(evidence.mcpTool)
534
+ }
523
535
  }
524
536
  if (evidence.didChannelMessage && didRepoMutation) didResultMessage = true
525
537
  didCode ||= !!evidence.didCode
@@ -588,6 +600,11 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
588
600
  function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfig, workdir, log, debug, model, onTool, systemPrompt }) {
589
601
  const canCode = !!workdir
590
602
  const maxCycleMs = canCode ? MAX_CODE_CYCLE_MS : MAX_CYCLE_MS
603
+ // Codex and OpenCode run through Mastra's ACP harness. Each queue worker gets
604
+ // its own ACP session and worktree, so cancellation, permissions and context
605
+ // cannot bleed across concurrently assigned tickets.
606
+ const mastraRunner = createMastraAcpRunner({ agent, mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt })
607
+ if (mastraRunner) return mastraRunner
591
608
  // opencode drives cycles differently — a headless `opencode run` per cycle rather
592
609
  // than a persistent stream-json session. Same { runCycle, canCode } contract.
593
610
  if (agent === 'opencode') return createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt })
@@ -768,29 +785,13 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
768
785
  function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug }) {
769
786
  const log = (m) => process.stdout.write('[ws ' + new Date().toISOString() + '] ' + m + '\n')
770
787
  let handle = null
771
- const statusBackoff = new Map()
772
- const statusInFlight = new Set()
773
- // Activity belongs to a lane. Keeping work/reply targets separate prevents a
774
- // quick reply from overwriting or clearing a long coding cycle's status.
775
- const laneStatusTargets = { work: new Set(), reply: new Set() }
776
- const sendStatus = (channelId, state) => {
777
- if (!backend || !['thinking', 'working', 'typing'].includes(state)) return
778
- const key = Number(channelId)
779
- if (statusInFlight.has(key) || (statusBackoff.get(key) || 0) > Date.now()) return
780
- let request
781
- try { request = agentStateRequest(backend, key, state, apiKey, identifier) } catch { return }
782
- statusInFlight.add(key)
783
- void fetch(request.url, { ...request.init, signal: AbortSignal.timeout(10_000) }).then(async (res) => {
784
- if (res.ok) { statusBackoff.delete(key); return }
785
- const body = (await res.text().catch(() => '')).replace(/\s+/g, ' ').slice(0, 160)
786
- statusBackoff.set(key, Date.now() + 30_000)
787
- log(`agent state HTTP ${res.status}${body ? ': ' + body : ''}; backing off 30s`)
788
- }).catch((e) => {
789
- statusBackoff.set(key, Date.now() + 30_000)
790
- log('agent state request failed: ' + (e?.message || e) + '; backing off 30s')
791
- }).finally(() => statusInFlight.delete(key))
792
- }
793
- const emitLaneStatus = (lane, state) => { for (const c of laneStatusTargets[lane]) sendStatus(c, state) }
788
+ // Activity belongs to an individual cycle. Independent ticket workers must
789
+ // not clear or overwrite each other's status targets.
790
+ const replyStatusTargets = new Set()
791
+ // Working/thinking/typing presence updates are intentionally silent. They
792
+ // generated notification noise without adding durable progress evidence.
793
+ const sendStatus = () => {}
794
+ const emitStatusTargets = (targets, state) => { for (const c of targets) sendStatus(c, state) }
794
795
  const canCode = !!workdir
795
796
  // The Mastra bridge authenticates per-CALL: every openvisio-team tool needs
796
797
  // agent_identifier + agent_api_key as arguments. Hand them over up front.
@@ -805,12 +806,23 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
805
806
  claude, agent, mcpUrl, mcpHeaders: { 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier },
806
807
  cfgKey: identifier, mcpConfig, workdir, log, debug, model, systemPrompt,
807
808
  }
808
- // One watcher and one WS subscription, but two independent model lanes. This
809
- // avoids duplicate event delivery while mentions can be answered during code.
810
- const runners = {
811
- work: createCycleRunner({ ...runnerOptions, onTool: (name) => { if (/post_message/.test(name)) emitLaneStatus('work', 'typing') } }),
812
- reply: createCycleRunner({ ...runnerOptions, workdir: '', systemPrompt: CHAT_CHARTER + '\n\n' + credNote, cfgKey: identifier + '-reply', onTool: (name) => { if (/post_message/.test(name)) emitLaneStatus('reply', 'typing') } }),
813
- }
809
+ // One watcher and one WS subscription, with a bounded pool of isolated coding
810
+ // runtimes. Replies remain serialized so one agent cannot answer two messages
811
+ // at once and race duplicate-delivery guards.
812
+ const MAX_CONCURRENT_WORKERS = 3
813
+ const activeWorkRunners = new Set()
814
+ const createWorkRunner = (control, workerWorkdir = '') => createCycleRunner({
815
+ ...runnerOptions,
816
+ ...(workerWorkdir ? { workdir: workerWorkdir } : {}),
817
+ onTool: (name) => { if (/post_message/.test(name)) emitStatusTargets(control.statusTargets, 'typing') },
818
+ })
819
+ const replyRunner = createCycleRunner({
820
+ ...runnerOptions,
821
+ workdir: '',
822
+ systemPrompt: CHAT_CHARTER + '\n\n' + credNote,
823
+ cfgKey: identifier + '-reply',
824
+ onTool: (name) => { if (/post_message/.test(name)) emitStatusTargets(replyStatusTargets, 'typing') },
825
+ })
814
826
  const codexPushGuide = agent === 'codex' && canCode
815
827
  ? '\n\nCODEX PR DELIVERY: when the repository exists in the local workspace, use that clone for branch creation, edits, tests, and commits; do not inspect or mutate it through linked-codebase MCP tools. To publish the local agent/* branch, run `openvisio-agent push-pr-branch` from the repository, then open the PR with `gh pr create`. The helper can only push HEAD to the matching agent/* branch on the exact authorized origin. If it reports OPENVISIO_PR_PUSH_AUTH_REQUIRED, do not retry or route around it. Report the one-time command `openvisio-agent authorize-pr-push` as the blocker. Use list_codebases/create_codebase_branch/create_codebase_commit/create_pull_request only as a fallback when the repository cannot be obtained locally.'
816
828
  : ''
@@ -825,18 +837,32 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
825
837
  let codeModel = model
826
838
  let liteModel = chatModel || model
827
839
 
828
- const lanes = {
829
- work: { activeDelivery: null, cancelled: false },
830
- reply: { activeDelivery: null, cancelled: false },
840
+ const queues = {
841
+ work: createCycleQueue({
842
+ concurrency: MAX_CONCURRENT_WORKERS,
843
+ groupKey: (item) => item.workdir || workdir || 'shared-workspace',
844
+ run: async (item) => {
845
+ const runner = createWorkRunner(item.control, item.workdir)
846
+ item.control.runner = runner
847
+ activeWorkRunners.add(runner)
848
+ try { return await executeCycle(item.kind, item.context, item.targetChannels, item.taskRef, item.delivery, runner, item.control, item.workdir) }
849
+ finally { activeWorkRunners.delete(runner); item.control.runner = null }
850
+ },
851
+ onError: (error, item) => {
852
+ pauseFailedTask(item.taskRef)
853
+ void finalizeFailedTaskPause(item.taskRef)
854
+ log('work cycle failed: ' + (error?.message || error))
855
+ },
856
+ }),
857
+ reply: createCycleQueue({
858
+ run: (item) => executeCycle(item.kind, item.context, item.targetChannels, item.taskRef, item.delivery, replyRunner, item.control, ''),
859
+ onError: (error, item) => {
860
+ pauseFailedTask(item.taskRef)
861
+ void finalizeFailedTaskPause(item.taskRef)
862
+ log('reply cycle failed: ' + (error?.message || error))
863
+ },
864
+ }),
831
865
  }
832
- const queues = Object.fromEntries(['work', 'reply'].map((laneName) => [laneName, createCycleQueue({
833
- run: (item) => executeCycle(item.kind, item.context, item.targetChannels, item.taskRef, item.delivery),
834
- onError: (error, item) => {
835
- pauseFailedTask(item.taskRef)
836
- void finalizeFailedTaskPause(item.taskRef)
837
- log(laneName + ' cycle failed: ' + (error?.message || error))
838
- },
839
- })]))
840
866
  // Tasks we've already reacted to (keyed task-id:agent — so a REASSIGNMENT to a
841
867
  // different agent re-triggers), so a noisy stream of task:updated events doesn't
842
868
  // re-acknowledge the same assignment.
@@ -851,7 +877,11 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
851
877
  const recentMentionSignatures = new Map(Array.isArray(replayState.recentMentionSignatures) ? replayState.recentMentionSignatures : [])
852
878
  const seenActivities = new Set(Array.isArray(replayState.seenActivities) ? replayState.seenActivities : [])
853
879
  const deliveredReplies = new Set(Array.isArray(replayState.deliveredReplies) ? replayState.deliveredReplies : [])
854
- const memory = createByoMemoryGraph({ path: join(OV_DIR, 'watch-' + slug + '-memory.json') })
880
+ const memory = createMastraMemory({
881
+ ledgerPath: join(OV_DIR, 'watch-' + slug + '-memory.json'),
882
+ databasePath: join(OV_DIR, 'watch-' + slug + '-mastra.db'),
883
+ resourceId: identifier,
884
+ })
855
885
  // Completion delivery is runtime-owned for assigned coding work. Persist both
856
886
  // pending and delivered keys so a reconnect can finish a missed notification
857
887
  // without re-running the model or posting the same result twice.
@@ -1024,11 +1054,11 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1024
1054
  const controlKey = threadControlKey(channelId, parentId)
1025
1055
  if (!controlKey) return
1026
1056
  memory.remember({ key: controlKey, kind: 'thread', state: 'cancelled', summary, refs: { channelId: Number(channelId), threadId: parentId } })
1027
- for (const [laneName, lane] of Object.entries(lanes)) {
1028
- queues[laneName].cancel((item) => sameDeliveryThread(item.delivery, channelId, parentId), () => {
1029
- lane.cancelled = true
1057
+ for (const [laneName, queue] of Object.entries(queues)) {
1058
+ queue.cancel((item) => sameDeliveryThread(item.delivery, channelId, parentId), (item) => {
1059
+ item.control.cancelled = true
1030
1060
  log(`${laneName} lane cancelled by a newer redirect/stand-down in thread ${parentId}`)
1031
- runners[laneName].cancelCurrent?.('thread-cancelled')
1061
+ void item.control.runner?.cancelCurrent?.('thread-cancelled')
1032
1062
  })
1033
1063
  }
1034
1064
  }
@@ -1410,40 +1440,40 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1410
1440
  function drain(kind, context, targetChannels = [], taskRef = null, delivery = null) {
1411
1441
  const laneName = kind === 'full' ? 'work' : 'reply'
1412
1442
  const key = taskRef ? `ticket:${taskRef.projectId}:${taskRef.ticketId}` : delivery?.key
1413
- return queues[laneName].enqueue({ kind, context, targetChannels, taskRef, delivery }, key)
1443
+ const itemWorkdir = laneName === 'work' && taskRef ? findTicketWorktree(workdir, taskRef.ticketId) : ''
1444
+ const control = { cancelled: false, runner: null, statusTargets: new Set() }
1445
+ return queues[laneName].enqueue({ kind, context, targetChannels, taskRef, delivery, workdir: itemWorkdir, control }, key)
1414
1446
  }
1415
1447
 
1416
- async function executeCycle(kind, context, targetChannels = [], taskRef = null, delivery = null) {
1448
+ async function executeCycle(kind, context, targetChannels = [], taskRef = null, delivery = null, runner, control, preparedWorkdir = '') {
1417
1449
  const laneName = kind === 'full' ? 'work' : 'reply'
1418
- const lane = lanes[laneName]
1419
- lane.cancelled = false
1420
- lane.activeDelivery = delivery
1450
+ const cycleControl = control || { cancelled: false, runner, statusTargets: new Set() }
1421
1451
  const ctx = context ? [context] : []
1422
1452
  const activeTaskRef = taskRef
1423
- const ticketWorktree = kind === 'full' && activeTaskRef ? findTicketWorktree(workdir, activeTaskRef.ticketId) : ''
1453
+ const ticketWorktree = preparedWorkdir || (kind === 'full' && activeTaskRef ? findTicketWorktree(workdir, activeTaskRef.ticketId) : '')
1424
1454
  const runnerOptions = {
1425
1455
  ...(delivery?.watcherOwned ? { disabledMcpTools: ['post_message'] } : {}),
1426
1456
  ...(ticketWorktree ? { workdir: ticketWorktree } : {}),
1427
1457
  }
1428
1458
  const targets = [...new Set(targetChannels.filter((id) => id != null && Number.isFinite(Number(id))).map(Number))]
1429
- laneStatusTargets[laneName] = new Set(targets)
1459
+ cycleControl.statusTargets = laneName === 'reply' ? replyStatusTargets : new Set(targets)
1460
+ if (laneName === 'reply') {
1461
+ replyStatusTargets.clear()
1462
+ for (const target of targets) replyStatusTargets.add(target)
1463
+ }
1430
1464
  // credNote + charter live in the cached system prompt now — the per-cycle
1431
1465
  // message is just the event context + the small base instruction.
1432
1466
  const memoryRefs = delivery
1433
1467
  ? { channelId: delivery.channelId, threadId: delivery.parentId }
1434
1468
  : activeTaskRef ? { projectId: activeTaskRef.projectId, ticketId: activeTaskRef.ticketId } : {}
1435
- const recalled = memory.context(memoryRefs)
1469
+ const recalled = await memory.context(memoryRefs)
1436
1470
  const prompt = (ctx.length ? ctx.join('\n') + '\n\n' : '') + (recalled ? recalled + '\n\n' : '') + baseFor(kind, delivery)
1437
1471
  // Chat-shaped cycles (mentions/intro) may run on the cheaper chat model; code
1438
1472
  // work (full/sweep) uses the main model.
1439
1473
  const useModel = agent === 'codex' ? codeModel : kind === 'full' ? codeModel : liteModel
1440
1474
  log('running ' + kind + ' cycle…' + (ctx.length ? ' (' + ctx.length + ' event' + (ctx.length === 1 ? '' : 's') + ')' : '') + (useModel ? ' [' + useModel + ']' : ''))
1441
- // Live status: "working" now + a heartbeat so the UI (and its TTL) stays lit
1442
- // through a long cycle; onTool flips it to "typing" when post_message fires.
1443
- emitLaneStatus(laneName, 'working')
1444
- // Backend activity TTL is refreshed well before expiry, but only once every
1445
- // 20 seconds so long coding runs do not create needless network/battery load.
1446
- const heartbeat = targets.length ? setInterval(() => emitLaneStatus(laneName, 'working'), 20_000) : null
1475
+ // Presence notifications are intentionally disabled; durable messages and
1476
+ // ticket transitions are the only user-visible progress signals.
1447
1477
  try {
1448
1478
  // The ticket may have been reassigned or handed to review while waiting.
1449
1479
  // Re-check at dequeue, before any model can edit the repository.
@@ -1460,10 +1490,10 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1460
1490
  return
1461
1491
  }
1462
1492
  }
1463
- if (lane.cancelled) return
1464
- const result = await runners[laneName].runCycle(prompt, useModel, runnerOptions)
1493
+ if (cycleControl.cancelled) return
1494
+ const result = await runner.runCycle(prompt, useModel, runnerOptions)
1465
1495
  let completionResult = result
1466
- if (lane.cancelled || result?.subtype === 'canceled') {
1496
+ if (cycleControl.cancelled || result?.subtype === 'canceled') {
1467
1497
  log(laneName + ' cycle cancelled; no blocker or reply will be published')
1468
1498
  return
1469
1499
  }
@@ -1498,8 +1528,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1498
1528
  if (kind === 'full' && cycleSucceeded(result) && missing.length) {
1499
1529
  log(agent + ' coding cycle incomplete; recovery requires: ' + missing.join(', '))
1500
1530
  const recoveryPrompt = `CONTINUE THE SAME OPENVISIO REQUEST. Your earlier output did not complete it. Missing runtime evidence: ${missing.join('; ')}. An intent or progress message is not completion. Continue the actual work now, verify it, and then provide one distinct final result or real blocker using the original delivery rule; a final result after an earlier progress message is explicitly allowed and required. Do not repeat the progress message. ${codexPushGuide}\n\nORIGINAL REQUEST AND ROUTING CONTEXT:\n${prompt}`
1501
- const recovery = await runners.work.runCycle(recoveryPrompt, codeModel, runnerOptions)
1502
- if (lane.cancelled || recovery?.subtype === 'canceled') return
1531
+ const recovery = await runner.runCycle(recoveryPrompt, codeModel, runnerOptions)
1532
+ if (cycleControl.cancelled || recovery?.subtype === 'canceled') return
1503
1533
  const recoveredResult = combineRuntimeWorkEvidence(result, recovery)
1504
1534
  const recoveryMissing = missingRuntimeWorkEvidence(recoveredResult, { ticketCycle, resultMessageRequired })
1505
1535
  if (!cycleSucceeded(recovery) || recoveryMissing.length) {
@@ -1539,9 +1569,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1539
1569
  }
1540
1570
  }
1541
1571
  } finally {
1542
- if (heartbeat) clearInterval(heartbeat)
1543
- laneStatusTargets[laneName].clear()
1544
- lane.activeDelivery = null
1572
+ cycleControl.statusTargets.clear()
1545
1573
  }
1546
1574
  }
1547
1575
 
@@ -1606,9 +1634,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1606
1634
  const key = `${projectId}:${ticketId}`
1607
1635
  if (!belongsToSelf) {
1608
1636
  for (const [name, queue] of Object.entries(queues)) {
1609
- queue.cancel((item) => String(item.taskRef?.projectId) === String(projectId) && String(item.taskRef?.ticketId) === String(ticketId), () => {
1610
- lanes[name].cancelled = true
1611
- void runners[name].cancelCurrent?.()
1637
+ queue.cancel((item) => String(item.taskRef?.projectId) === String(projectId) && String(item.taskRef?.ticketId) === String(ticketId), (item) => {
1638
+ item.control.cancelled = true
1639
+ void item.control.runner?.cancelCurrent?.()
1612
1640
  })
1613
1641
  }
1614
1642
  memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'unassigned', summary: ticket.title, refs: { projectId, ticketId } })
@@ -1802,11 +1830,13 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1802
1830
  if (taskProbeStartTimer) clearTimeout(taskProbeStartTimer)
1803
1831
  if (taskProbeTimer) clearInterval(taskProbeTimer)
1804
1832
  try { handle && handle.close() } catch { /* noop */ }
1805
- for (const [name, queue] of Object.entries(queues)) {
1806
- lanes[name].cancelled = true
1807
- queue.cancel(() => true)
1833
+ for (const queue of Object.values(queues)) {
1834
+ queue.cancel(() => true, (item) => {
1835
+ item.control.cancelled = true
1836
+ void item.control.runner?.cancelCurrent?.()
1837
+ })
1808
1838
  }
1809
- await Promise.all(Object.values(runners).map((runner) => runner.cancelCurrent?.()))
1839
+ await Promise.all([replyRunner.cancelCurrent?.(), ...[...activeWorkRunners].map((runner) => runner.cancelCurrent?.())])
1810
1840
  process.exit(0)
1811
1841
  }
1812
1842
  process.on('SIGTERM', bye)