openvisio-agent 0.18.0 → 0.18.1

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
@@ -69,7 +69,16 @@ openvisio-agent watch --name ada --workdir ~/repo # allow REAL work on a git br
69
69
  openvisio-agent stop --name ada # stop service + every ada watcher
70
70
  ```
71
71
 
72
- With `--workdir`, the agent gets file + Bash tools scoped to that repo and works on a branch. Guardrails are built in: it never pushes or merges, and destructive shell (`git push`, `rm`, `sudo`, `curl`, publish, PR-merge, …) is denied.
72
+ With `--workdir`, the agent gets file + shell tools scoped to the workspace and works on an `agent/*` branch. For a codebase linked to OpenVisio, Codex first uses the authenticated MCP branch, commit, and pull-request tools. That creates the PR through the existing integration and avoids a local `git push`.
73
+
74
+ When those linked-codebase tools are unavailable, a pull request still requires exporting the local branch to the private Git remote. The constrained fallback uses an explicit, one-time authorization per repository:
75
+
76
+ ```bash
77
+ cd /path/to/private-repo
78
+ openvisio-agent authorize-pr-push
79
+ ```
80
+
81
+ 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`.
73
82
 
74
83
  `--install` sets up a background service (launchd on macOS, systemd `--user` on Linux) that runs `watch` and restarts on login. Logs go to `~/.openvisio/<agent>.log` (macOS) or `journalctl --user -u openvisio-<agent>` (Linux).
75
84
 
@@ -79,7 +88,7 @@ Do not chase auto-changing watcher PIDs. `openvisio-agent stop --name <agent>` u
79
88
 
80
89
  - **No opaque script.** You run a named, versioned npm package you can read here and on [npmjs.com](https://www.npmjs.com/package/openvisio-agent).
81
90
  - **Single-use code.** The `ovs_` code is exchanged once for a key; a leaked code is already spent.
82
- - **Least privilege.** Chat mode exposes only the `openvisio-team` MCP tools. Coding mode is opt-in per repo, branch-only, with a shell denylist.
91
+ - **Least privilege.** Chat mode exposes only the `openvisio-team` MCP tools. Codex private-repository pushes require an explicit per-repository opt-in and go through a no-argument `agent/*`-only helper; merges and protected-branch pushes remain unavailable.
83
92
  - **Local secrets.** Your agent key lives in `~/.openvisio/` with `600` permissions — never printed, never committed.
84
93
 
85
94
  ## Requirements
package/bin/cli.mjs CHANGED
@@ -15,6 +15,7 @@ import { fileURLToPath } from 'node:url'
15
15
  import { dirname, join } from 'node:path'
16
16
  import { parseFlags, slugify, stripSlash, exchangeToken, ensureClaude, ensureCodex, writeJson, mcpConfigPath, configPath, chmodSafe, onPath, OV_DIR, fail, ok, info } from '../src/lib.mjs'
17
17
  import { runWatch, installService, stopWatchers } from '../src/watch.mjs'
18
+ import { authorizePrPush, pushPrBranch, revokePrPush } from '../src/pr-push.mjs'
18
19
 
19
20
  const HERE = dirname(fileURLToPath(import.meta.url))
20
21
  const VERSION = (() => { try { return JSON.parse(readFileSync(join(HERE, '..', 'package.json'), 'utf8')).version } catch { return '0.0.0' } })()
@@ -28,6 +29,9 @@ Usage:
28
29
  openvisio-agent connect --backend <url> --key <api-key> --id <identifier> [--name "<agent>"] [--ws <wss-url>] [--mcp-url <url>] [--agent claude|codex|opencode]
29
30
  openvisio-agent watch --name <agent> [--install] [--workspace <dir>] [--chat-only] [--model <m>] [--chat-model <m>] [--debug]
30
31
  openvisio-agent stop --name <agent>
32
+ openvisio-agent authorize-pr-push [--repo <dir>]
33
+ openvisio-agent push-pr-branch
34
+ openvisio-agent revoke-pr-push [--repo <dir>]
31
35
  openvisio-agent --help | --version
32
36
 
33
37
  connect
@@ -78,6 +82,19 @@ stop
78
82
  remaining watcher with that exact --name and clears its stale lock. Use this
79
83
  instead of killing changing PIDs: openvisio-agent stop --name Alex
80
84
 
85
+ authorize-pr-push
86
+ One-time, explicit authorization for the current private repository. Installs a
87
+ narrow Codex command rule and records the exact repository root + origin. The
88
+ permitted helper can only push the current agent/* branch and cannot force-push,
89
+ choose another remote/ref, push a protected branch, or merge.
90
+
91
+ push-pr-branch
92
+ Pushes HEAD to the same agent/* branch on an origin previously authorized with
93
+ authorize-pr-push. Intended for Codex work cycles; accepts no arguments.
94
+
95
+ revoke-pr-push
96
+ Removes the current repository from the helper's authorization list.
97
+
81
98
  Docs: https://www.npmjs.com/package/openvisio-agent`
82
99
 
83
100
  // Register the `openvisio-team` MCP at USER (global) scope so it's available in
@@ -291,6 +308,29 @@ async function main() {
291
308
  if (!name) fail('Missing agent name.\n Usage: openvisio-agent stop --name <agent>')
292
309
  return stopWatchers({ slug: slugify(name) })
293
310
  }
311
+ if (cmd === 'authorize-pr-push') {
312
+ const cwd = String(rest.flags.repo || rest.positional[0] || process.cwd())
313
+ const result = authorizePrPush({ cwd })
314
+ ok('Authorized constrained PR-branch pushes for this repository.')
315
+ info(`Repository: ${result.root}`)
316
+ info(`Origin: ${result.remote}`)
317
+ info('Codex can now run: openvisio-agent push-pr-branch')
318
+ info('This does not allow pushes to main/master, force pushes, arbitrary remotes, or merges.')
319
+ return
320
+ }
321
+ if (cmd === 'push-pr-branch') {
322
+ if (rest.positional.length || Object.keys(rest.flags).length) fail('push-pr-branch accepts no arguments. It uses the current repository, origin, and agent/* branch.')
323
+ const result = pushPrBranch()
324
+ ok(`Pushed ${result.branch} to its matching origin branch.`)
325
+ return
326
+ }
327
+ if (cmd === 'revoke-pr-push') {
328
+ const cwd = String(rest.flags.repo || rest.positional[0] || process.cwd())
329
+ const result = revokePrPush({ cwd })
330
+ if (result.revoked) ok(`Revoked constrained PR pushes for ${result.root}.`)
331
+ else info(`No PR-push authorization was stored for ${result.root}.`)
332
+ return
333
+ }
294
334
  fail(`Unknown command "${cmd}".\n\n${HELP}`)
295
335
  }
296
336
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.18.0",
3
+ "version": "0.18.1",
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": {
@@ -21,6 +21,10 @@ run('package dry run', 'npm', ['pack', '--dry-run'])
21
21
  run('diff whitespace check', 'git', ['diff', '--check'], repo)
22
22
 
23
23
  const watcher = readFileSync(join(root, 'src', 'watch.mjs'), 'utf8')
24
+ const events = readFileSync(join(root, 'src', 'events.mjs'), 'utf8')
25
+ const memory = readFileSync(join(root, 'src', 'memory.mjs'), 'utf8')
26
+ const prPush = readFileSync(join(root, 'src', 'pr-push.mjs'), 'utf8')
27
+ const cli = readFileSync(join(root, 'bin', 'cli.mjs'), 'utf8')
24
28
  const websocket = readFileSync(join(root, 'src', 'ws.mjs'), 'utf8')
25
29
  const activityHook = readFileSync(join(repo, 'frontend', 'hooks', 'useAgentActivity.ts'), 'utf8')
26
30
  const taskHook = readFileSync(join(repo, 'frontend', 'hooks', 'useBackendTasks.ts'), 'utf8')
@@ -31,13 +35,19 @@ const assertions = [
31
35
  ['task signals are verified with get_ticket', watcher.includes("callMcpTool('get_ticket'")],
32
36
  ['review and testing handoffs do not restart work', watcher.includes('taskIsAwaitingReview(task, reviewIds)') && watcher.includes('taskIsAwaitingReview(ticket)')],
33
37
  ['review handoff releases the task key for future rework', watcher.includes('seenTasks.delete(taskKey)') && watcher.includes('seenTasks.delete(key)')],
34
- ['assigned coding completion is posted by the watcher', watcher.includes('announceTaskCompletion') && watcher.includes("callMcpTool('post_message', { project_id: projectId, channel_id: channelId, content: report.content })")],
38
+ ['assigned coding completion is posted by the watcher', watcher.includes('announceTaskCompletion') && watcher.includes('postMessageOnce({ key: `completion:${report.key}`')],
35
39
  ['completion requires review/done state and PR evidence', watcher.includes('buildTaskCompletionReport') && watcher.includes('completion report deferred')],
36
40
  ['completion delivery survives reconnect and deduplicates', watcher.includes('pendingCompletionReports: [...pendingCompletionReports]') && watcher.includes('reportedCompletions: [...reportedCompletions]') && watcher.includes('reportedCompletions.has(report.key)')],
37
41
  ['verified completion is persisted as one ticket comment', watcher.includes("callMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: report.content })") && watcher.includes('reportedTaskComments: [...reportedTaskComments]') && watcher.includes('reportedTaskComments.has(report.key)')],
38
42
  ['ticket comments cannot masquerade as channel completion', watcher.includes('didChannelMessage') && watcher.includes("mcpCalls.includes('post_message')")],
39
43
  ['single-watcher acquisition is atomic and fails closed', watcher.includes("openSync(lockPath, 'wx')") && watcher.includes('Could not acquire the single-watcher lock')],
40
44
  ['websocket and activity mention delivery share a replay guard', watcher.includes('markMentionHandled(activityMessage, activityChannelId)') && watcher.includes('markMentionHandled(msg, cid)') && watcher.includes('recentMentionSignatures')],
45
+ ['reconciled mentions reuse the guarded websocket delivery path', watcher.includes("onEvent('agent:mention'") && watcher.includes('_mentionAlreadyMarked: true')],
46
+ ['rendered backend replies are checked before every guarded thread post', watcher.includes("callMcpTool('list_message_thread'") && watcher.includes('renderedAgentMessages(live') && watcher.includes('same-content-rendered')],
47
+ ['Codex cannot race the watcher with post_message', watcher.includes("disabledMcpTools: ['post_message']") && watcher.includes('disabled_tools = [')],
48
+ ['reply delivery keys survive reconnects', watcher.includes('deliveredReplies: [...deliveredReplies]') && watcher.includes('deliveredReplies.has(deliveryKey)')],
49
+ ['guarded replies stay independent while a lane is busy', watcher.includes('lane.deferred.push') && watcher.includes('lane.deferred.shift()')],
50
+ ['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)')],
41
51
  ['two internal lanes exist', watcher.includes('work: createCycleRunner') && watcher.includes('reply: createCycleRunner')],
42
52
  ['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')")],
43
53
  ['assigned task activity resolves a project channel', watcher.includes("callMcpTool('list_channels'") && watcher.includes('projectStatusChannel(projectId)')],
@@ -56,6 +66,11 @@ const assertions = [
56
66
  ['Codex recoverable subprocess diagnostics are not surfaced as activity', watcher.includes('shouldSuppressCodexDiagnostic(line)') && watcher.includes("forwardDiagnostic('', true)") && watcher.includes('RECOVER DEAD COMMAND SESSIONS')],
57
67
  ['policy rejection cannot be logged as successful', watcher.includes("subtype = policyBlock ? 'blocked'")],
58
68
  ['policy-blocked tickets are persisted and paused', watcher.includes('blockedTasks: [...blockedTasks]') && watcher.includes('WORK_CYCLE_BLOCKED')],
69
+ ['repository push authorization automatically resumes the paused ticket', watcher.includes('blockedTaskRepos: [...blockedTaskRepos]') && watcher.includes('repositoryHasPrPushAuthorization')],
70
+ ['Codex prefers linked-codebase MCP PR delivery', watcher.includes('CODEX PR DELIVERY') && watcher.includes('create_codebase_branch/create_codebase_commit/create_pull_request') && events.includes('const codebaseMutation')],
71
+ ['private PR pushes use an explicit constrained helper', cli.includes("cmd === 'authorize-pr-push'") && cli.includes("cmd === 'push-pr-branch'") && prPush.includes("'push', '-u', 'origin', destination")],
72
+ ['PR push helper rejects protected/alternate/force targets by construction', prPush.includes("/^agent\\/") && prPush.includes('entry?.root === root && entry?.remote === remote') && prPush.includes('accepts no force, remote, or ref args')],
73
+ ['Codex recognizes helper authorization as a blocker', events.includes('OPENVISIO_PR_PUSH_AUTH_REQUIRED') && watcher.includes("block?.kind === 'pr-push-authorization-required'")],
59
74
  ['policy blocker is surfaced to the user', watcher.includes('reportPolicyBlock(prompt, activeTaskRef, result.policyBlock)') && watcher.includes("Action required: I'm blocked")],
60
75
  ['blocker routing carries explicit task identity', watcher.includes('taskRefs: []') && watcher.includes('activeTaskRef') && watcher.includes('taskRef: activeTaskRef')],
61
76
  ['all runtime blockers have a delivery path', watcher.includes('publishBlocker') && watcher.includes('WORK_CYCLE_BLOCKED') && watcher.includes('COORDINATION_CYCLE_BLOCKED')],
package/src/events.mjs CHANGED
@@ -95,8 +95,9 @@ export function opencodeEventEvidence(event) {
95
95
  const lowerTool = tool.toLowerCase()
96
96
  const prefixed = /^(?:mcp__)?openvisio(?:-team|_team)(?:__|[_.:/-])(.+)$/i.exec(tool)
97
97
  const bareTool = lowerTool.replace(/[-.]/g, '_')
98
- const knownMcp = /^(?:get_ticket|list_tasks|list_task_types|update_ticket|post_message|comment_ticket|react_message|list_projects|list_agents|list_activity)$/
98
+ const knownMcp = /^(?:get_ticket|list_tasks|list_task_types|update_ticket|post_message|comment_ticket|react_message|list_projects|list_agents|list_activity|list_codebases|create_codebase_branch|create_codebase_commit|write_codebase_file|create_pull_request)$/
99
99
  const mcpTool = (prefixed?.[1] ? prefixed[1].replace(/[-.]/g, '_') : (knownMcp.test(bareTool) ? bareTool : '')).toLowerCase()
100
+ const codebaseMutation = /^(?:create_codebase_branch|create_codebase_commit|write_codebase_file|create_pull_request)$/.test(mcpTool)
100
101
  const mutationTool = /^(?:edit|write|patch|apply_patch|multiedit|multi_edit)$/i.test(tool)
101
102
  const bashTool = /^(?:bash|shell|terminal|exec|command)$/i.test(tool)
102
103
  const commandMutation = /\b(?:git\s+(?:commit|push)|gh\s+pr\s+create)\b/i.test(command)
@@ -106,8 +107,8 @@ export function opencodeEventEvidence(event) {
106
107
  ...(mcpTool ? { mcpTool } : {}),
107
108
  failed,
108
109
  completed,
109
- didCode: completed && (mutationTool || bashTool),
110
- didRepoMutation: completed && (mutationTool || (bashTool && commandMutation)),
110
+ didCode: completed && (mutationTool || bashTool || codebaseMutation),
111
+ didRepoMutation: completed && (mutationTool || codebaseMutation || (bashTool && commandMutation)),
111
112
  didMcpTaskRead: completed && /^(?:get_ticket|list_tasks|list_task_types)$/.test(mcpTool),
112
113
  didMcpTaskUpdate: completed && mcpTool === 'update_ticket',
113
114
  didMessage: completed && /^(?:post_message|comment_ticket)$/.test(mcpTool),
@@ -131,6 +132,16 @@ export function agentStateRequest(backend, channelId, state, apiKey, identifier)
131
132
 
132
133
  export function codexPolicyBlock(value) {
133
134
  const text = String(value || '')
135
+ if (/OPENVISIO_PR_PUSH_AUTH_REQUIRED/i.test(text)) {
136
+ const root = /From\s+([^\n,]+),\s*run:\s*openvisio-agent authorize-pr-push/i.exec(text)?.[1]?.trim() || ''
137
+ return {
138
+ kind: 'pr-push-authorization-required',
139
+ command: 'openvisio-agent push-pr-branch',
140
+ reason: 'The repository has not received the one-time constrained PR-branch authorization.',
141
+ root,
142
+ setupCommand: 'openvisio-agent authorize-pr-push',
143
+ }
144
+ }
134
145
  if (!/rejected due to unacceptable risk|action was rejected due to unacceptable risk|explicitly approves? the action/i.test(text)) return null
135
146
  const command = /exec_command failed for [`']([^`']+)[`']/.exec(text)?.[1] || ''
136
147
  const reasonTail = text.split(/Reason:\s*/i)[1] || ''
@@ -172,6 +183,54 @@ export function mentionDedupeKeys(message, channelId) {
172
183
  }
173
184
  }
174
185
 
186
+ export function normalizeRenderedMessageText(value) {
187
+ return String(value || '').replace(/\s+/g, ' ').trim().toLowerCase()
188
+ }
189
+
190
+ // Extract only messages visibly authored by this agent from the backend's live
191
+ // thread response. REST and WebSocket payloads use several sender shapes, so this
192
+ // mirrors the frontend normalizer instead of trusting one field name.
193
+ export function renderedAgentMessages(value, identity = {}) {
194
+ const rows = []
195
+ const seen = new Set()
196
+ const visit = (node, depth = 0) => {
197
+ if (depth > 6 || node == null) return
198
+ if (Array.isArray(node)) { for (const item of node) visit(item, depth + 1); return }
199
+ if (typeof node !== 'object') return
200
+ const row = node
201
+ const content = row.content ?? row.body ?? row.text ?? (typeof row.message === 'string' ? row.message : undefined)
202
+ const rowId = row.id ?? row.message_id ?? row.messageId
203
+ if (content != null && rowId != null) {
204
+ const sender = [row.sender, row.user, row.author, row.member].find((item) => item && typeof item === 'object') || {}
205
+ const expanded = [row.senderAgent, row.sender_agent, row.agent, sender.agent].find((item) => item && typeof item === 'object')
206
+ const senderKind = String(sender.type ?? sender.kind ?? sender.sender_type ?? row.sender_type ?? row.author_type ?? '').toLowerCase()
207
+ const senderLooksLikeAgent = /agent|bot/.test(senderKind) || sender.agent_id != null || sender.identifier != null || (sender.slug != null && !sender.email)
208
+ const agent = expanded || (senderLooksLikeAgent ? sender : {})
209
+ const agentId = Number(agent.id ?? agent.agent_id ?? row.agent_id ?? row.sender_agent_id ?? row.senderAgentId ?? NaN)
210
+ const identifier = String(agent.identifier ?? agent.slug ?? row.agent_identifier ?? row.sender_identifier ?? '')
211
+ const name = String(agent.name ?? agent.display_name ?? (senderLooksLikeAgent ? sender.name : '') ?? '')
212
+ const aliases = new Set([identity.identifier, identity.slug, identity.name].map((item) => String(item || '').toLowerCase()).filter(Boolean))
213
+ const isSelf = (Number.isFinite(Number(identity.id)) && agentId === Number(identity.id)) || aliases.has(identifier.toLowerCase()) || (!!expanded && aliases.has(name.toLowerCase()))
214
+ if (isSelf) {
215
+ const key = String(rowId)
216
+ if (!seen.has(key)) {
217
+ seen.add(key)
218
+ rows.push({
219
+ id: rowId,
220
+ parentId: row.parent_id ?? row.parentId ?? row.thread_id ?? null,
221
+ content: String(content),
222
+ })
223
+ }
224
+ }
225
+ }
226
+ for (const key of ['messages', 'replies', 'items', 'data', 'result', 'thread', 'message']) {
227
+ if (row[key] && typeof row[key] === 'object') visit(row[key], depth + 1)
228
+ }
229
+ }
230
+ visit(value)
231
+ return rows
232
+ }
233
+
175
234
  // A mention event means this agent's name appeared somewhere, not necessarily
176
235
  // that the request was addressed to it. Reject a later-agent hand-off before a
177
236
  // model starts, while keeping explicitly shared requests addressed to both.
package/src/memory.mjs ADDED
@@ -0,0 +1,82 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { dirname } from 'node:path'
3
+
4
+ const clean = (value, max = 320) => String(value || '').replace(/\s+/g, ' ').trim().slice(0, max)
5
+ const sameRef = (a, b) => a != null && b != null && String(a) === String(b)
6
+
7
+ // Small, deterministic memory graph for BYO watchers. Nodes are real events,
8
+ // tickets and deliveries; edges record what a reply answered or where a task was
9
+ // reported. It is intentionally not an LLM transcript or vector store: stable
10
+ // backend ids make exact recall cheaper and prevent old work from being replayed.
11
+ export function createByoMemoryGraph({ path, maxNodes = 1000, now = () => Date.now() }) {
12
+ let raw = {}
13
+ try { raw = JSON.parse(readFileSync(path, 'utf8')) } catch { /* first run */ }
14
+ const nodes = new Map((Array.isArray(raw.nodes) ? raw.nodes : []).filter((node) => node?.key).map((node) => [String(node.key), node]))
15
+ const edges = new Map((Array.isArray(raw.edges) ? raw.edges : []).filter((edge) => edge?.from && edge?.to && edge?.relation).map((edge) => [`${edge.from}|${edge.relation}|${edge.to}`, edge]))
16
+
17
+ const persist = () => {
18
+ try {
19
+ mkdirSync(dirname(path), { recursive: true })
20
+ writeFileSync(path, JSON.stringify({ version: 1, nodes: [...nodes.values()], edges: [...edges.values()] }, null, 2) + '\n', { mode: 0o600 })
21
+ } catch { /* memory is best-effort; live backend checks remain authoritative */ }
22
+ }
23
+ const trim = () => {
24
+ if (nodes.size <= maxNodes) return
25
+ const oldest = [...nodes.values()].sort((a, b) => Number(a.updatedAt || 0) - Number(b.updatedAt || 0)).slice(0, nodes.size - maxNodes)
26
+ const removed = new Set(oldest.map((node) => String(node.key)))
27
+ for (const key of removed) nodes.delete(key)
28
+ for (const [key, edge] of edges) if (removed.has(String(edge.from)) || removed.has(String(edge.to))) edges.delete(key)
29
+ }
30
+
31
+ const remember = ({ key, kind, state, summary, refs = {}, meta = {} }) => {
32
+ const id = String(key || '')
33
+ if (!id) return null
34
+ const previous = nodes.get(id)
35
+ const stamp = now()
36
+ const node = {
37
+ ...(previous || { key: id, createdAt: stamp }),
38
+ kind: clean(kind, 40) || previous?.kind || 'event',
39
+ state: clean(state, 40) || previous?.state || 'observed',
40
+ summary: clean(summary) || previous?.summary || '',
41
+ refs: { ...(previous?.refs || {}), ...refs },
42
+ meta: { ...(previous?.meta || {}), ...meta },
43
+ updatedAt: stamp,
44
+ }
45
+ nodes.set(id, node); trim(); persist()
46
+ return node
47
+ }
48
+
49
+ const connect = (from, to, relation) => {
50
+ const edge = { from: String(from || ''), to: String(to || ''), relation: clean(relation, 50), updatedAt: now() }
51
+ if (!edge.from || !edge.to || !edge.relation) return null
52
+ edges.set(`${edge.from}|${edge.relation}|${edge.to}`, edge); persist()
53
+ return edge
54
+ }
55
+
56
+ const recall = (refs = {}, limit = 8) => {
57
+ const direct = [...nodes.values()].filter((node) => {
58
+ const r = node.refs || {}
59
+ return sameRef(r.channelId, refs.channelId) && (refs.threadId == null || sameRef(r.threadId, refs.threadId)) ||
60
+ sameRef(r.projectId, refs.projectId) && sameRef(r.ticketId, refs.ticketId)
61
+ })
62
+ const keys = new Set(direct.map((node) => String(node.key)))
63
+ for (const edge of edges.values()) {
64
+ if (keys.has(String(edge.from))) keys.add(String(edge.to))
65
+ if (keys.has(String(edge.to))) keys.add(String(edge.from))
66
+ }
67
+ return [...nodes.values()].filter((node) => keys.has(String(node.key))).sort((a, b) => Number(b.updatedAt || 0) - Number(a.updatedAt || 0)).slice(0, limit)
68
+ }
69
+
70
+ const context = (refs = {}, limit = 8) => {
71
+ const items = recall(refs, limit)
72
+ if (!items.length) return ''
73
+ return ['RELEVANT VERIFIED MEMORY (do not repeat completed/delivered actions):', ...items.map((node) => `- ${node.kind} ${node.state}: ${node.summary || node.key}`)].join('\n')
74
+ }
75
+
76
+ const has = (key, state) => {
77
+ const node = nodes.get(String(key || ''))
78
+ return !!node && (state == null || node.state === state)
79
+ }
80
+
81
+ return { remember, connect, recall, context, has, persist }
82
+ }
@@ -0,0 +1,112 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
3
+ import { homedir } from 'node:os'
4
+ import { dirname, join } from 'node:path'
5
+
6
+ const DEFAULT_AUTH_PATH = join(homedir(), '.openvisio', 'pr-push-authorizations.json')
7
+ const DEFAULT_RULE_PATH = join(homedir(), '.codex', 'rules', 'openvisio-agent.rules')
8
+
9
+ const gitText = (cwd, args, spawn = spawnSync) => {
10
+ const result = spawn('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
11
+ if (result.status !== 0) throw new Error(String(result.stderr || result.stdout || `git ${args.join(' ')} failed`).trim())
12
+ return String(result.stdout || '').trim()
13
+ }
14
+
15
+ const safeRemote = (value) => {
16
+ const remote = String(value || '').trim()
17
+ if (/^https?:\/\//i.test(remote) || /^ssh:\/\//i.test(remote)) {
18
+ const parsed = new URL(remote)
19
+ if (parsed.username || parsed.password) throw new Error('Origin contains credentials in its URL. Move credentials to your Git credential manager before authorizing PR pushes.')
20
+ return remote
21
+ }
22
+ if (/^[^@\s]+@[^:\s]+:[^\s]+$/.test(remote)) return remote
23
+ throw new Error('Origin must be an HTTPS or SSH repository URL. Local paths and file:// remotes cannot be authorized.')
24
+ }
25
+
26
+ export function isSafeAgentBranch(value) {
27
+ const branch = String(value || '')
28
+ return /^agent\/[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(branch) &&
29
+ !branch.includes('..') && !branch.includes('//') && !branch.includes('@{') &&
30
+ !/[~^:?*\[\\]/.test(branch) && !/[/.]$/.test(branch)
31
+ }
32
+
33
+ export function codexPrPushRule() {
34
+ return `# Generated by: openvisio-agent authorize-pr-push
35
+ # The helper enforces an exact pre-authorized repository + origin and only
36
+ # pushes the current agent/* branch. It accepts no force, remote, or ref args.
37
+ prefix_rule(
38
+ pattern = ["openvisio-agent", "push-pr-branch"],
39
+ decision = "allow",
40
+ justification = "The user explicitly authorized OpenVisio's constrained agent-branch PR push helper.",
41
+ match = ["openvisio-agent push-pr-branch"],
42
+ )
43
+ `
44
+ }
45
+
46
+ function readAuthorizations(path) {
47
+ try {
48
+ const value = JSON.parse(readFileSync(path, 'utf8'))
49
+ return Array.isArray(value.authorizations) ? value.authorizations : []
50
+ } catch { return [] }
51
+ }
52
+
53
+ function writePrivate(path, value) {
54
+ mkdirSync(dirname(path), { recursive: true })
55
+ writeFileSync(path, value, { mode: 0o600 })
56
+ try { chmodSync(path, 0o600) } catch { /* Windows / best-effort */ }
57
+ }
58
+
59
+ export function repositoryPushIdentity(cwd = process.cwd(), spawn = spawnSync) {
60
+ const root = realpathSync(gitText(cwd, ['rev-parse', '--show-toplevel'], spawn))
61
+ const remote = safeRemote(gitText(root, ['remote', 'get-url', '--push', 'origin'], spawn))
62
+ const branch = gitText(root, ['branch', '--show-current'], spawn)
63
+ return { root, remote, branch }
64
+ }
65
+
66
+ export function validatePrPushAuthorization({ root, remote, branch, authorizations }) {
67
+ if (!isSafeAgentBranch(branch)) {
68
+ throw new Error(`Refusing to push branch "${branch || '(detached HEAD)'}". OpenVisio PR pushes require the current branch to match agent/*.`)
69
+ }
70
+ const allowed = (Array.isArray(authorizations) ? authorizations : []).some((entry) => entry?.root === root && entry?.remote === remote)
71
+ if (!allowed) {
72
+ throw new Error(`OPENVISIO_PR_PUSH_AUTH_REQUIRED: This repository and its exact origin are not authorized. From ${root}, run: openvisio-agent authorize-pr-push`)
73
+ }
74
+ return true
75
+ }
76
+
77
+ export function repositoryHasPrPushAuthorization({ cwd = process.cwd(), authPath = DEFAULT_AUTH_PATH, spawn = spawnSync } = {}) {
78
+ try {
79
+ const { root, remote } = repositoryPushIdentity(cwd, spawn)
80
+ return readAuthorizations(authPath).some((entry) => entry?.root === root && entry?.remote === remote)
81
+ } catch { return false }
82
+ }
83
+
84
+ export function authorizePrPush({ cwd = process.cwd(), authPath = DEFAULT_AUTH_PATH, rulePath = DEFAULT_RULE_PATH, spawn = spawnSync, now = () => new Date().toISOString() } = {}) {
85
+ const { root, remote } = repositoryPushIdentity(cwd, spawn)
86
+ const authorizations = readAuthorizations(authPath).filter((entry) => entry?.root !== root)
87
+ authorizations.push({ root, remote, authorizedAt: now() })
88
+ writePrivate(authPath, JSON.stringify({ version: 1, authorizations }, null, 2) + '\n')
89
+ writePrivate(rulePath, codexPrPushRule())
90
+ return { root, remote, authPath, rulePath }
91
+ }
92
+
93
+ export function revokePrPush({ cwd = process.cwd(), authPath = DEFAULT_AUTH_PATH, spawn = spawnSync } = {}) {
94
+ const root = realpathSync(gitText(cwd, ['rev-parse', '--show-toplevel'], spawn))
95
+ const before = readAuthorizations(authPath)
96
+ const authorizations = before.filter((entry) => entry?.root !== root)
97
+ if (existsSync(authPath)) writePrivate(authPath, JSON.stringify({ version: 1, authorizations }, null, 2) + '\n')
98
+ return { root, revoked: authorizations.length !== before.length }
99
+ }
100
+
101
+ export function pushPrBranch({ cwd = process.cwd(), authPath = DEFAULT_AUTH_PATH, spawn = spawnSync } = {}) {
102
+ const identity = repositoryPushIdentity(cwd, spawn)
103
+ validatePrPushAuthorization({ ...identity, authorizations: readAuthorizations(authPath) })
104
+ const destination = `HEAD:refs/heads/${identity.branch}`
105
+ // The helper runs outside Codex's workspace sandbox after the user authorizes
106
+ // it. Disable repository hooks so an edited pre-push hook cannot widen this
107
+ // one operation into arbitrary host execution.
108
+ const nullHooks = process.platform === 'win32' ? 'NUL' : '/dev/null'
109
+ const result = spawn('git', ['-c', `core.hooksPath=${nullHooks}`, 'push', '-u', 'origin', destination], { cwd: identity.root, stdio: 'inherit' })
110
+ if (result.status !== 0) throw new Error(`git push failed with exit code ${result.status ?? 'unknown'}`)
111
+ return identity
112
+ }
package/src/watch.mjs CHANGED
@@ -10,7 +10,9 @@ import { homedir } from 'node:os'
10
10
  import { join, dirname } from 'node:path'
11
11
  import { OV_DIR, DEFAULT_WORKSPACE, readConfig, writeJson, configPath, onPath, fail, ok, info, slugify, stripSlash, chmodSafe } from './lib.mjs'
12
12
  import { connectAgentWs, assertWebSocket } from './ws.mjs'
13
- import { agentStateRequest, buildTaskCompletionReport, codexPolicyBlock, mentionDedupeKeys, opencodeEventEvidence, requestTargetsLaterAgent, shouldSuppressCodexDiagnostic, taskAgentId, taskFromEvent, taskIsAwaitingReview, taskIsCompleted } from './events.mjs'
13
+ import { agentStateRequest, buildTaskCompletionReport, codexPolicyBlock, mentionDedupeKeys, normalizeRenderedMessageText, opencodeEventEvidence, renderedAgentMessages, requestTargetsLaterAgent, shouldSuppressCodexDiagnostic, taskAgentId, taskFromEvent, taskIsAwaitingReview, taskIsCompleted } from './events.mjs'
14
+ import { createByoMemoryGraph } from './memory.mjs'
15
+ import { repositoryHasPrPushAuthorization } from './pr-push.mjs'
14
16
 
15
17
  // Behaviour prompts. The openvisio-team MCP bridge requires the agent's
16
18
  // credentials as ARGUMENTS on every tool call — those are injected at runtime by
@@ -402,12 +404,14 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
402
404
  const tomlString = (v) => JSON.stringify(String(v))
403
405
  const headerEntries = Object.entries(mcpHeaders || {}).map(([k, v]) => `${JSON.stringify(k)} = ${tomlString(v)}`).join(', ')
404
406
 
405
- function runCycle(prompt, cycleModel) {
407
+ function runCycle(prompt, cycleModel, cycleOptions = {}) {
406
408
  return new Promise((resolve) => {
407
409
  const m = cycleModel || model
408
410
  const full = systemPrompt ? systemPrompt + '\n\n' + prompt : prompt
411
+ const disabledMcpTools = Array.isArray(cycleOptions.disabledMcpTools) ? cycleOptions.disabledMcpTools.filter(Boolean) : []
412
+ const disabledToolConfig = disabledMcpTools.length ? `, disabled_tools = [${disabledMcpTools.map(tomlString).join(', ')}]` : ''
409
413
  const mcpOverride = mcpUrl
410
- ? `mcp_servers={ openvisio-team = { url = ${tomlString(mcpUrl)}${headerEntries ? `, http_headers = { ${headerEntries} }` : ''} } }`
414
+ ? `mcp_servers={ openvisio-team = { url = ${tomlString(mcpUrl)}${headerEntries ? `, http_headers = { ${headerEntries} }` : ''}${disabledToolConfig} } }`
411
415
  : ''
412
416
  const args = ['exec', '--ignore-user-config', '--skip-git-repo-check', '--ephemeral', '--json', '--color', 'never',
413
417
  ...(canCode ? ['--approve-for-me'] : ['--sandbox', 'read-only']),
@@ -448,8 +452,9 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
448
452
  const inspectLine = (line) => {
449
453
  const s = line.trim()
450
454
  if (!s) return
455
+ policyBlock = codexPolicyBlock(s) || policyBlock
451
456
  if (/command_execution|file_change|apply_patch|shell_command|exec_command/i.test(s)) didCode = true
452
- if (/file_change|apply_patch/i.test(s) || /\b(?:git\s+(?:commit|push)|gh\s+pr\s+create)\b/i.test(s)) didRepoMutation = true
457
+ if (/file_change|apply_patch/i.test(s) || /\b(?:git\s+(?:commit|push)|gh\s+pr\s+create|openvisio-agent\s+push-pr-branch)\b/i.test(s)) didRepoMutation = true
453
458
  if (/post_message|comment_ticket/i.test(s)) didMessage = true
454
459
  try {
455
460
  const event = JSON.parse(s)
@@ -461,6 +466,7 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
461
466
  try { onTool && onTool(tool) } catch { /* activity is best-effort */ }
462
467
  if (/^(?:get_ticket|list_tasks|list_task_types)$/.test(tool)) didMcpTaskRead = true
463
468
  if (tool === 'update_ticket') didMcpTaskUpdate = true
469
+ if (/^(?:create_codebase_branch|create_codebase_commit|write_codebase_file|create_pull_request)$/.test(tool)) { didCode = true; didRepoMutation = true }
464
470
  if (/post_message|comment_ticket/.test(tool)) didMessage = true
465
471
  if (/post_message/.test(tool)) didChannelMessage = true
466
472
  if (/fail|error/i.test(String(item.status || '')) || item.error) mcpErrors.add(tool)
@@ -678,7 +684,10 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
678
684
  work: createCycleRunner({ ...runnerOptions, onTool: (name) => { if (/post_message/.test(name)) emitLaneStatus('work', 'typing') } }),
679
685
  reply: createCycleRunner({ ...runnerOptions, cfgKey: identifier + '-reply', onTool: (name) => { if (/post_message/.test(name)) emitLaneStatus('reply', 'typing') } }),
680
686
  }
681
- const fullPrompt = canCode ? CODE_FULL : CYCLE
687
+ const codexPushGuide = agent === 'codex' && canCode
688
+ ? '\n\nCODEX PR DELIVERY: first inspect the OpenVisio MCP tools. When list_codebases, create_codebase_branch, create_codebase_commit (or write_codebase_file), and create_pull_request are available, use that authenticated linked-codebase flow to create the agent/* branch, publish the verified changed files, and open the PR. This is the preferred path and requires no local git push. If those tools are unavailable for the repository, do not run git push directly. From the repository run `openvisio-agent push-pr-branch`. It is a user-authorized constrained fallback that 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.'
689
+ : ''
690
+ const fullPrompt = canCode ? CODE_FULL + codexPushGuide : CYCLE
682
691
  const fastPrompt = canCode ? CODE_FAST : CYCLE_FAST
683
692
  // Live model state — changeable at runtime by the in-chat `/model` command.
684
693
  // codeModel drives full/sweep cycles; chatModel (if set) the lighter fast/intro
@@ -687,8 +696,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
687
696
  let liteModel = chatModel || model
688
697
 
689
698
  const lanes = {
690
- work: { busy: false, queued: null, pending: [], targets: new Set(), taskRefs: [] },
691
- reply: { busy: false, queued: null, pending: [], targets: new Set(), taskRefs: [] },
699
+ work: { busy: false, queued: null, pending: [], targets: new Set(), taskRefs: [], deferred: [] },
700
+ reply: { busy: false, queued: null, pending: [], targets: new Set(), taskRefs: [], deferred: [] },
692
701
  }
693
702
  // Tasks we've already reacted to (keyed task-id:agent — so a REASSIGNMENT to a
694
703
  // different agent re-triggers), so a noisy stream of task:updated events doesn't
@@ -703,6 +712,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
703
712
  const seenMentions = new Set(Array.isArray(replayState.seenMentions) ? replayState.seenMentions : [])
704
713
  const recentMentionSignatures = new Map(Array.isArray(replayState.recentMentionSignatures) ? replayState.recentMentionSignatures : [])
705
714
  const seenActivities = new Set(Array.isArray(replayState.seenActivities) ? replayState.seenActivities : [])
715
+ const deliveredReplies = new Set(Array.isArray(replayState.deliveredReplies) ? replayState.deliveredReplies : [])
716
+ const memory = createByoMemoryGraph({ path: join(OV_DIR, 'watch-' + slug + '-memory.json') })
706
717
  // Completion delivery is runtime-owned for assigned coding work. Persist both
707
718
  // pending and delivered keys so a reconnect can finish a missed notification
708
719
  // without re-running the model or posting the same result twice.
@@ -714,6 +725,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
714
725
  // unassigned. This prevents a 30-minute reconciliation retry from repeatedly
715
726
  // attempting the same rejected egress action.
716
727
  const blockedTasks = new Set(Array.isArray(replayState.blockedTasks) ? replayState.blockedTasks : [])
728
+ const blockedTaskRepos = new Map(Array.isArray(replayState.blockedTaskRepos) ? replayState.blockedTaskRepos : [])
717
729
  const trimSeen = (set) => { while (set.size > 500) set.delete(set.values().next().value) }
718
730
  const MENTION_SIGNATURE_TTL_MS = 10 * 60 * 1000
719
731
  const pruneMentionSignatures = () => {
@@ -728,7 +740,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
728
740
  seenMentions: [...seenMentions],
729
741
  recentMentionSignatures: [...recentMentionSignatures],
730
742
  seenActivities: [...seenActivities],
743
+ deliveredReplies: [...deliveredReplies],
731
744
  blockedTasks: [...blockedTasks],
745
+ blockedTaskRepos: [...blockedTaskRepos],
732
746
  pendingCompletionReports: [...pendingCompletionReports],
733
747
  reportedCompletions: [...reportedCompletions],
734
748
  reportedTaskComments: [...reportedTaskComments],
@@ -778,7 +792,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
778
792
  }
779
793
  const ensureMcpSession = async () => {
780
794
  if (mcpSessionId) return
781
- const res = await mcpPost({ jsonrpc: '2.0', id: ++mcpRpcId, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'openvisio-agent', version: '0.18.0' } } }, false)
795
+ const res = await mcpPost({ jsonrpc: '2.0', id: ++mcpRpcId, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'openvisio-agent', version: '0.18.1' } } }, false)
782
796
  if (!res.ok) throw new Error('MCP initialize HTTP ' + res.status)
783
797
  await mcpPayload(res)
784
798
  mcpSessionId = res.headers.get('mcp-session-id') || ''
@@ -797,7 +811,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
797
811
  if (payload.error) throw new Error(`MCP ${name}: ${payload.error.message || 'tool error'}`)
798
812
  const result = payload.result ?? payload
799
813
  if (result?.isError) {
800
- const detail = result.content?.find?.((c) => c?.type === 'text')?.text || 'tool error'
814
+ const detail = String(result.content?.find?.((c) => c?.type === 'text')?.text || 'tool error').split(apiKey).join('[redacted]')
801
815
  throw new Error(`MCP ${name}: ${detail}`)
802
816
  }
803
817
  return result
@@ -808,6 +822,48 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
808
822
  try { return JSON.parse(text) } catch { return { text } }
809
823
  }
810
824
 
825
+ // All watcher-owned message delivery goes through this gate. For threaded
826
+ // replies it first reads the live backend thread and inspects rows already
827
+ // rendered as this agent. A persisted delivery key closes the crash/reconnect
828
+ // gap; an in-flight promise closes the two-lane race inside one watcher.
829
+ const messageDeliveries = new Map()
830
+ const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '' }) => {
831
+ const deliveryKey = String(key || '')
832
+ const message = String(content || '').trim()
833
+ if (!deliveryKey || !Number.isFinite(Number(channelId)) || !message) return { posted: false, reason: 'invalid-delivery' }
834
+ if (deliveredReplies.has(deliveryKey) || memory.has(deliveryKey, 'rendered')) return { posted: false, reason: 'remembered' }
835
+ if (messageDeliveries.has(deliveryKey)) return messageDeliveries.get(deliveryKey)
836
+
837
+ const run = (async () => {
838
+ if (parentId != null) {
839
+ const live = toolData(await callMcpTool('list_message_thread', { channel_id: Number(channelId), message_id: Number(parentId) }))
840
+ const rendered = renderedAgentMessages(live, { id: selfAgentId, identifier, slug, name: slug })
841
+ const duplicate = rendered.some((row) => normalizeRenderedMessageText(row.content) === normalizeRenderedMessageText(message))
842
+ if (duplicate || (skipIfAnyAgentReply && rendered.length)) {
843
+ deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
844
+ memory.remember({ key: deliveryKey, kind: 'delivery', state: 'rendered', summary: duplicate ? message : rendered.at(-1)?.content, refs: { channelId: Number(channelId), threadId: Number(parentId) }, meta: { discoveredFromBackend: true } })
845
+ if (sourceKey) memory.connect(deliveryKey, sourceKey, 'responds_to')
846
+ log('message delivery ' + deliveryKey + ' already rendered — skipped')
847
+ return { posted: false, reason: duplicate ? 'same-content-rendered' : 'agent-reply-rendered' }
848
+ }
849
+ }
850
+
851
+ sendStatus(Number(channelId), 'typing')
852
+ const result = toolData(await callMcpTool('post_message', {
853
+ ...(Number.isFinite(Number(projectId)) ? { project_id: Number(projectId) } : {}),
854
+ channel_id: Number(channelId),
855
+ ...(parentId != null ? { parent_id: Number(parentId) } : {}),
856
+ content: message,
857
+ }))
858
+ deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
859
+ memory.remember({ key: deliveryKey, kind: 'delivery', state: 'rendered', summary: message, refs: { channelId: Number(channelId), ...(parentId != null ? { threadId: Number(parentId) } : {}), ...(Number.isFinite(Number(projectId)) ? { projectId: Number(projectId) } : {}) }, meta: { messageId: result.id ?? result.message?.id ?? null } })
860
+ if (sourceKey) memory.connect(deliveryKey, sourceKey, 'responds_to')
861
+ return { posted: true, result }
862
+ })().finally(() => messageDeliveries.delete(deliveryKey))
863
+ messageDeliveries.set(deliveryKey, run)
864
+ return run
865
+ }
866
+
811
867
  const statusChannelCache = new Map()
812
868
  const projectStatusChannel = async (projectId) => {
813
869
  const key = Number(projectId)
@@ -829,6 +885,22 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
829
885
  }
830
886
  }
831
887
 
888
+ const announceIntroduction = async () => {
889
+ const projectsData = toolData(await callMcpTool('list_projects'))
890
+ const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
891
+ const project = projects.find((item) => Number.isFinite(Number(item.id)))
892
+ if (!project) { log('no project available for first-connection introduction'); return false }
893
+ const channelId = await projectStatusChannel(project.id)
894
+ if (!Number.isFinite(Number(channelId))) return false
895
+ await postMessageOnce({
896
+ key: `intro:${identifier}:${project.id}`,
897
+ projectId: Number(project.id),
898
+ channelId: Number(channelId),
899
+ content: "I'm here, I pick up tasks assigned to me, and I respond to @mentions. Send work my way whenever you need me.",
900
+ })
901
+ return true
902
+ }
903
+
832
904
  const announceTaskCompletion = async (taskRef, result = {}) => {
833
905
  const projectId = Number(taskRef?.projectId)
834
906
  const ticketId = Number(taskRef?.ticketId)
@@ -838,6 +910,15 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
838
910
  const ticket = current.ticket ?? current.task ?? current
839
911
  const report = buildTaskCompletionReport(ticket, { projectId, fallbackText: result.outputText })
840
912
  if (!report) return false
913
+ const memoryKey = `ticket:${projectId}:${ticketId}`
914
+ memory.remember({
915
+ key: memoryKey,
916
+ kind: 'ticket',
917
+ state: 'handoff',
918
+ summary: report.content,
919
+ refs: { projectId, ticketId },
920
+ meta: { reportKey: report.key, prUrl: report.prUrl },
921
+ })
841
922
  // Ticket comments are now a backend first-class surface. The watcher owns the
842
923
  // final comment so every runtime (Claude, Codex, OpenCode) closes the ticket
843
924
  // loop consistently, and the persisted report key prevents reconnect repeats.
@@ -862,10 +943,10 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
862
943
  }
863
944
  const channelId = Number.isFinite(Number(taskRef.channelId)) ? Number(taskRef.channelId) : await projectStatusChannel(projectId)
864
945
  if (!Number.isFinite(channelId)) return false
865
- sendStatus(channelId, 'typing')
866
- await callMcpTool('post_message', { project_id: projectId, channel_id: channelId, content: report.content })
946
+ await postMessageOnce({ key: `completion:${report.key}`, projectId, channelId, content: report.content, sourceKey: memoryKey })
867
947
  reportedCompletions.add(report.key); trimSeen(reportedCompletions)
868
948
  pendingCompletionReports.delete(taskKey); persistReplay()
949
+ memory.remember({ key: memoryKey, kind: 'ticket', state: 'reported', summary: report.content, refs: { projectId, ticketId, channelId }, meta: { reportKey: report.key, prUrl: report.prUrl } })
869
950
  log('posted verified completion for ticket #' + ticketId + ' in channel ' + channelId)
870
951
  return true
871
952
  }
@@ -882,11 +963,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
882
963
  let delivered = false
883
964
  if (Number.isFinite(channelId)) {
884
965
  try {
885
- await callMcpTool('post_message', {
886
- channel_id: channelId,
887
- ...(parentMatch ? { parent_id: Number(parentMatch[1]) } : {}),
888
- content: notice,
889
- })
966
+ const parentId = parentMatch ? Number(parentMatch[1]) : null
967
+ const blockerKey = `blocker:${channelId}:${parentId ?? 'top'}:${normalizeRenderedMessageText(notice).slice(0, 180)}`
968
+ await postMessageOnce({ key: blockerKey, channelId, parentId, projectId, content: notice, sourceKey: Number.isFinite(ticketId) && Number.isFinite(projectId) ? `ticket:${projectId}:${ticketId}` : '' })
890
969
  delivered = true
891
970
  } catch (e) {
892
971
  log('failed to post blocker in channel ' + channelId + ': ' + (e?.message || e))
@@ -899,6 +978,13 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
899
978
 
900
979
  const key = `${projectId}:${ticketId}`
901
980
  if (pause) { blockedTasks.add(key); persistReplay() }
981
+ memory.remember({
982
+ key: `ticket:${projectId}:${ticketId}`,
983
+ kind: 'ticket',
984
+ state: pause ? 'authorization-blocked' : 'blocked',
985
+ summary: ticketNotice,
986
+ refs: { projectId, ticketId, ...(Number.isFinite(channelId) ? { channelId } : {}) },
987
+ })
902
988
  try {
903
989
  await callMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: ticketNotice })
904
990
  delivered = true
@@ -921,6 +1007,18 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
921
1007
  }
922
1008
 
923
1009
  const reportPolicyBlock = async (prompt, taskRef, block) => {
1010
+ if (block?.kind === 'pr-push-authorization-required') {
1011
+ const location = block.root ? ` from \`${block.root}\`` : ' from the repository'
1012
+ const notice = `Action required: run \`openvisio-agent authorize-pr-push\`${location}. This is a one-time, repository-scoped opt-in. It permits only the constrained \`openvisio-agent push-pr-branch\` helper for the current \`agent/*\` branch, never main/master, force pushes, another remote, or merges. I've paused the ticket until it is enabled.`
1013
+ const ticketNotice = `I'm paused before the PR push. Run \`openvisio-agent authorize-pr-push\`${location}; the watcher will resume this ticket after the repository-scoped helper is authorized.`
1014
+ const projectId = Number(taskRef?.projectId)
1015
+ const ticketId = Number(taskRef?.ticketId)
1016
+ if (block.root && Number.isFinite(projectId) && Number.isFinite(ticketId)) {
1017
+ blockedTaskRepos.set(`${projectId}:${ticketId}`, block.root)
1018
+ persistReplay()
1019
+ }
1020
+ return publishBlocker({ prompt, taskRef, notice, ticketNotice, pause: true })
1021
+ }
924
1022
  const command = block?.command || 'the requested external repository action'
925
1023
  const payload = [block?.commit && `commit ${block.commit}`, block?.branch && `branch ${block.branch}`, block?.remote && `remote ${block.remote}`].filter(Boolean).join(', ')
926
1024
  const approval = block?.commit && block?.branch
@@ -962,12 +1060,13 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
962
1060
  if (taskAgentId !== Number(self.id) && taskIdent !== identifier) continue
963
1061
  const taskKey = `${project.id}:${task.id}`
964
1062
  if (taskIsCompleted(task, doneIds) || taskIsAwaitingReview(task, reviewIds)) {
1063
+ memory.remember({ key: `ticket:${project.id}:${task.id}`, kind: 'ticket', state: 'handoff', summary: task.title, refs: { projectId: project.id, ticketId: task.id } })
965
1064
  if (pendingCompletionReports.has(taskKey)) {
966
1065
  const activityChannel = await projectStatusChannel(project.id)
967
1066
  try { await announceTaskCompletion({ projectId: project.id, ticketId: task.id, channelId: activityChannel }) }
968
1067
  catch (e) { log('completion report retry failed for ticket #' + task.id + ': ' + (e?.message || e)) }
969
1068
  }
970
- if (blockedTasks.delete(taskKey)) persistReplay()
1069
+ if (blockedTasks.delete(taskKey)) { blockedTaskRepos.delete(taskKey); persistReplay() }
971
1070
  // Release the in-flight de-dupe key at handoff. If a reviewer moves
972
1071
  // the ticket back to an actionable column, that update must start a
973
1072
  // fresh work cycle.
@@ -976,11 +1075,14 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
976
1075
  }
977
1076
  if (blockedTasks.has(taskKey)) {
978
1077
  const approvalText = [task.title, task.description].filter(Boolean).join(' ')
979
- if (/\b(?:i\s+)?(?:explicitly\s+)?(?:approve|authorize)\b[\s\S]{0,240}\b(?:git\s+push|push(?:ing)?\s+(?:the\s+)?(?:branch|code)|github|remote)\b/i.test(approvalText)) {
980
- blockedTasks.delete(taskKey); seenTasks.delete(taskKey); persistReplay()
981
- log('backlog ticket #' + task.id + ' now contains explicit push authorization — resuming')
1078
+ const blockedRepo = blockedTaskRepos.get(taskKey)
1079
+ const helperAuthorized = blockedRepo && repositoryHasPrPushAuthorization({ cwd: blockedRepo })
1080
+ if (helperAuthorized || /\b(?:i\s+)?(?:explicitly\s+)?(?:approve|authorize)\b[\s\S]{0,240}\b(?:git\s+push|push(?:ing)?\s+(?:the\s+)?(?:branch|code)|github|remote)\b/i.test(approvalText)) {
1081
+ blockedTasks.delete(taskKey); blockedTaskRepos.delete(taskKey); seenTasks.delete(taskKey); persistReplay()
1082
+ log('backlog ticket #' + task.id + (helperAuthorized ? ' has repository-scoped PR push authorization' : ' now contains explicit push authorization') + ' — resuming')
982
1083
  } else continue
983
1084
  }
1085
+ memory.remember({ key: `ticket:${project.id}:${task.id}`, kind: 'ticket', state: 'assigned', summary: task.title, refs: { projectId: project.id, ticketId: task.id }, meta: { updatedAt: task.updated_at ?? task.updatedAt } })
984
1086
  assigned.push({ id: task.id, projectId: project.id, project: project.name, title: task.title, priority: task.priority, typeId: task.type_id ?? task.typeId, updatedAt: task.updated_at ?? task.updatedAt })
985
1087
  }
986
1088
  const activities = Array.isArray(activityData.activities) ? activityData.activities : Array.isArray(activityData.activity) ? activityData.activity : []
@@ -999,7 +1101,13 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
999
1101
  // The same logical mention may already have arrived over WebSocket.
1000
1102
  // Share the id/signature guard instead of starting a second model turn.
1001
1103
  if (markMentionHandled(activityMessage, activityChannelId)) continue
1002
- mentionActivity.push({ projectId: project.id, project: project.name, activity: item })
1104
+ mentionActivity.push({
1105
+ projectId: project.id,
1106
+ project: project.name,
1107
+ channelId: activityChannelId,
1108
+ message: activityMessage,
1109
+ activity: item,
1110
+ })
1003
1111
  }
1004
1112
  }
1005
1113
  }
@@ -1022,8 +1130,18 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1022
1130
  const inboxSignature = JSON.stringify(mentionActivity.slice(-20)).slice(0, 4000)
1023
1131
  if (mentionActivity.length && inboxSignature !== lastInboxSignature) {
1024
1132
  lastInboxSignature = inboxSignature
1025
- log('backlog reconciliation found mention activity -> reply cycle')
1026
- void drain('fast', `Recent project activity contains these messages mentioning YOU: ${inboxSignature}. Handle each still-unanswered mention once using the channel/message ids in the activity. Do not call nonexistent poll_inbox or get_marching_orders tools. Skip anything already answered by you.`)
1133
+ log('backlog reconciliation found ' + mentionActivity.length + ' mention(s) -> guarded reply cycle(s)')
1134
+ // Replay each real message through the exact same delivery path as a live
1135
+ // WebSocket mention. This preserves thread ids and lets postMessageOnce
1136
+ // consult the rendered thread before any reply is emitted.
1137
+ for (const mention of mentionActivity) {
1138
+ onEvent('agent:mention', {
1139
+ project_id: mention.projectId,
1140
+ channel_id: mention.channelId,
1141
+ message: mention.message,
1142
+ _mentionAlreadyMarked: true,
1143
+ })
1144
+ }
1027
1145
  } else if (!mentionActivity.length) lastInboxSignature = ''
1028
1146
  } catch (e) {
1029
1147
  mcpSessionId = ''
@@ -1074,9 +1192,16 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1074
1192
  lastTaskSignature = ''
1075
1193
  }
1076
1194
 
1077
- async function drain(kind, context, targetChannels = [], taskRef = null) {
1195
+ async function drain(kind, context, targetChannels = [], taskRef = null, delivery = null) {
1078
1196
  const laneName = kind === 'full' ? 'work' : 'reply'
1079
1197
  const lane = lanes[laneName]
1198
+ // A guarded reply is never coalesced with another event. It carries one
1199
+ // source message and one delivery key, so queue it as an independent cycle.
1200
+ if (lane.busy && delivery) {
1201
+ lane.deferred.push({ kind, context, targetChannels, taskRef, delivery })
1202
+ log(laneName + ' lane busy — queued one guarded ' + kind + ' cycle')
1203
+ return
1204
+ }
1080
1205
  if (context) lane.pending.push(context)
1081
1206
  if (taskRef) lane.taskRefs.push(taskRef)
1082
1207
  for (const channelId of targetChannels) if (Number.isFinite(Number(channelId))) lane.targets.add(Number(channelId))
@@ -1089,7 +1214,11 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1089
1214
  laneStatusTargets[laneName] = new Set(targets)
1090
1215
  // credNote + charter live in the cached system prompt now — the per-cycle
1091
1216
  // message is just the event context + the small base instruction.
1092
- const prompt = (ctx.length ? ctx.join('\n') + '\n\n' : '') + baseFor(kind)
1217
+ const memoryRefs = delivery
1218
+ ? { channelId: delivery.channelId, threadId: delivery.parentId }
1219
+ : activeTaskRef ? { projectId: activeTaskRef.projectId, ticketId: activeTaskRef.ticketId } : {}
1220
+ const recalled = memory.context(memoryRefs)
1221
+ const prompt = (ctx.length ? ctx.join('\n') + '\n\n' : '') + (recalled ? recalled + '\n\n' : '') + baseFor(kind)
1093
1222
  // Chat-shaped cycles (mentions/intro) may run on the cheaper chat model; code
1094
1223
  // work (full/sweep) uses the main model.
1095
1224
  const useModel = agent === 'codex' ? codeModel : kind === 'full' ? codeModel : liteModel
@@ -1101,7 +1230,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1101
1230
  // 20 seconds so long coding runs do not create needless network/battery load.
1102
1231
  const heartbeat = targets.length ? setInterval(() => emitLaneStatus(laneName, 'working'), 20_000) : null
1103
1232
  try {
1104
- const result = await runners[laneName].runCycle(prompt, useModel)
1233
+ const result = await runners[laneName].runCycle(prompt, useModel, agent === 'codex' && delivery ? { disabledMcpTools: ['post_message'] } : {})
1105
1234
  let completionResult = result
1106
1235
  if (agent === 'codex' && result?.subtype === 'blocked' && result?.policyBlock) {
1107
1236
  log('WORK_CYCLE_BLOCKED authorization required; pausing this ticket and publishing an action-required notice')
@@ -1130,7 +1259,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1130
1259
  const missing = missingWorkEvidence(result, ticketCycle)
1131
1260
  if (evidenceGatedRuntime && kind === 'full' && result?.subtype === 'ok' && missing.length) {
1132
1261
  log(agent + ' coding cycle incomplete; recovery requires: ' + missing.join(', '))
1133
- const recovery = await runners.work.runCycle(`The assigned task is NOT complete. Missing runtime evidence: ${missing.join('; ')}. Do not post an acknowledgement or claim success. Resume now. Use get_ticket/list_tasks and list_task_types, perform and verify the repository work, commit and push an agent/* branch, open the PR, and call update_ticket with the correct board column. Post only when the original context supplies a source thread.`, codeModel)
1262
+ const recovery = await runners.work.runCycle(`The assigned task is NOT complete. Missing runtime evidence: ${missing.join('; ')}. Do not post an acknowledgement or claim success. Resume now. Use get_ticket/list_tasks and list_task_types, perform and verify the repository work, publish an agent/* branch, open the PR, and call update_ticket with the correct board column. ${agent === 'codex' ? 'Prefer the available OpenVisio create_codebase_branch/create_codebase_commit/create_pull_request tools. Only when that linked-codebase flow is unavailable, run openvisio-agent push-pr-branch; never retry a rejected direct git push.' : ''} Post only when the original context supplies a source thread.`, codeModel, agent === 'codex' && delivery ? { disabledMcpTools: ['post_message'] } : {})
1134
1263
  const recoveredResult = combineWorkEvidence(result, recovery)
1135
1264
  const recoveryMissing = missingWorkEvidence(recoveredResult, ticketCycle)
1136
1265
  if (recovery?.subtype !== 'ok' || recoveryMissing.length) {
@@ -1161,11 +1290,22 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1161
1290
  log('completion report failed for ticket #' + activeTaskRef.ticketId + ': ' + (e?.message || e) + '; retained for reconnect retry')
1162
1291
  }
1163
1292
  }
1293
+ if (agent === 'codex' && delivery) {
1294
+ const reply = String(completionResult?.outputText || '').trim()
1295
+ if (!reply) {
1296
+ log('guarded reply produced no final text; leaving delivery unrecorded for retry')
1297
+ } else {
1298
+ try { await postMessageOnce({ ...delivery, content: reply }) }
1299
+ catch (e) { log('guarded reply delivery failed closed: ' + (e?.message || e)) }
1300
+ }
1301
+ }
1164
1302
  } finally {
1165
1303
  if (heartbeat) clearInterval(heartbeat)
1166
1304
  laneStatusTargets[laneName].clear()
1167
1305
  lane.busy = false
1168
- if (lane.queued || lane.pending.length) { const next = lane.queued || (laneName === 'work' ? 'full' : 'fast'); lane.queued = null; void drain(next) }
1306
+ const deferred = lane.deferred.shift()
1307
+ if (deferred) void drain(deferred.kind, deferred.context, deferred.targetChannels, deferred.taskRef, deferred.delivery)
1308
+ else if (lane.queued || lane.pending.length) { const next = lane.queued || (laneName === 'work' ? 'full' : 'fast'); lane.queued = null; void drain(next) }
1169
1309
  }
1170
1310
  }
1171
1311
 
@@ -1229,22 +1369,28 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1229
1369
  const assignedIdentifier = String(ticket.agent?.identifier ?? ticket.assigned_agent?.identifier ?? '')
1230
1370
  const belongsToSelf = (selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier
1231
1371
  const key = `${projectId}:${ticketId}`
1232
- if (!belongsToSelf) { blockedTasks.delete(key); pendingCompletionReports.delete(key); persistReplay(); seenTasks.delete(key); log(kind + ' ticket #' + ticketId + ' is not assigned to this agent — ignored'); return }
1372
+ if (!belongsToSelf) {
1373
+ memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'unassigned', summary: ticket.title, refs: { projectId, ticketId } })
1374
+ blockedTasks.delete(key); blockedTaskRepos.delete(key); pendingCompletionReports.delete(key); persistReplay(); seenTasks.delete(key); log(kind + ' ticket #' + ticketId + ' is not assigned to this agent — ignored'); return
1375
+ }
1233
1376
  if (taskIsCompleted(ticket) || taskIsAwaitingReview(ticket)) {
1377
+ memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'handoff', summary: ticket.title, refs: { projectId, ticketId } })
1234
1378
  if (pendingCompletionReports.has(key)) {
1235
1379
  const activityChannel = await projectStatusChannel(projectId)
1236
1380
  try { await announceTaskCompletion({ projectId, ticketId, channelId: activityChannel }) }
1237
1381
  catch (e) { log('completion report failed for ticket #' + ticketId + ': ' + (e?.message || e) + '; retained for reconnect retry') }
1238
1382
  }
1239
- blockedTasks.delete(key); persistReplay(); seenTasks.delete(key)
1383
+ blockedTasks.delete(key); blockedTaskRepos.delete(key); persistReplay(); seenTasks.delete(key)
1240
1384
  log(kind + ' ticket #' + ticketId + ' is already complete or awaiting review — ignored')
1241
1385
  return
1242
1386
  }
1243
1387
  if (blockedTasks.has(key)) {
1244
1388
  const approvalText = [ticket.title, ticket.description].filter(Boolean).join(' ')
1245
- if (/\b(?:i\s+)?(?:explicitly\s+)?(?:approve|authorize)\b[\s\S]{0,240}\b(?:git\s+push|push(?:ing)?\s+(?:the\s+)?(?:branch|code)|github|remote)\b/i.test(approvalText)) {
1246
- blockedTasks.delete(key); seenTasks.delete(key); persistReplay()
1247
- log(kind + ' ticket #' + ticketId + ' contains explicit push authorization — resuming')
1389
+ const blockedRepo = blockedTaskRepos.get(key)
1390
+ const helperAuthorized = blockedRepo && repositoryHasPrPushAuthorization({ cwd: blockedRepo })
1391
+ if (helperAuthorized || /\b(?:i\s+)?(?:explicitly\s+)?(?:approve|authorize)\b[\s\S]{0,240}\b(?:git\s+push|push(?:ing)?\s+(?:the\s+)?(?:branch|code)|github|remote)\b/i.test(approvalText)) {
1392
+ blockedTasks.delete(key); blockedTaskRepos.delete(key); seenTasks.delete(key); persistReplay()
1393
+ log(kind + ' ticket #' + ticketId + (helperAuthorized ? ' has repository-scoped PR push authorization' : ' contains explicit push authorization') + ' — resuming')
1248
1394
  } else {
1249
1395
  log(kind + ' ticket #' + ticketId + ' is paused for explicit repository push authorization — ignored')
1250
1396
  return
@@ -1253,6 +1399,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1253
1399
  if (seenTasks.has(key)) { log(kind + ' ticket #' + ticketId + ' already queued/active — ignored'); return }
1254
1400
  seenTasks.add(key); trimSeen(seenTasks)
1255
1401
  const title = String(ticket.title || hinted.title || '')
1402
+ memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'queued', summary: title, refs: { projectId, ticketId }, meta: { sourceEvent: kind } })
1256
1403
  const taskText = [title, ticket.description, ticket.type, ticket.kind].filter(Boolean).join(' ')
1257
1404
  const cycleKind = canCode && !coordinationOnly(taskText) ? 'full' : 'coord'
1258
1405
  log(kind + ' verified ticket #' + ticketId + ' “' + title + '” -> ' + cycleKind + ' lane')
@@ -1282,20 +1429,28 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1282
1429
  // De-dupe: the same mention re-delivered (reconnect replay / dup fan-out) must
1283
1430
  // NOT trigger a second reply. Key by message id, or a channel+text signature
1284
1431
  // when the payload carries no id.
1285
- if (markMentionHandled(msg, cid)) { log('agent:mention (dup) — skipped'); return }
1432
+ if (!raw._mentionAlreadyMarked && markMentionHandled(msg, cid)) { log('agent:mention (dup) — skipped'); return }
1286
1433
  if (requestTargetsLaterAgent(text, [slug, identifier])) {
1287
1434
  log('agent:mention addressed to a later-mentioned agent — skipped')
1288
1435
  return
1289
1436
  }
1437
+ const mentionKeys = mentionDedupeKeys(msg, cid)
1438
+ const sourceKey = `mention:${cid ?? '?'}:${mentionKeys.idKey || mentionKeys.signatureKey || threadRoot || Date.now()}`
1439
+ memory.remember({ key: sourceKey, kind: 'mention', state: 'received', summary: text, refs: { channelId: cid, threadId: threadRoot, messageId: mid } })
1440
+ const guardedDelivery = (stage = 'reply', skipIfAnyAgentReply = stage !== 'result') => agent === 'codex' && cid != null
1441
+ ? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey }
1442
+ : null
1290
1443
  // Under-the-hood model control from chat (view / switch the model the agent runs).
1291
1444
  const mcmd = cid != null ? parseModelCmd(text) : null
1292
1445
  if (mcmd) {
1293
1446
  const thread = threadRoot != null ? `, parent_id ${threadRoot}` : ''
1294
1447
  if (mcmd.report) {
1295
1448
  log('model query → code ' + codeModel + ' / chat ' + liteModel)
1296
- void drain('fast', `An engineer asked which model you're running. Reply once in channel ${cid}${thread} (with your agent creds): "I'm on ${codeModel} for code work${liteModel !== codeModel ? ` and ${liteModel} for chat replies` : ' (and chat)'}." One line. Then stop.`)
1449
+ const delivery = guardedDelivery('model')
1450
+ void drain('fast', `An engineer asked which model you're running. ${delivery ? `Return exactly this one-line final answer without calling post_message: "I'm on ${codeModel} for code work${liteModel !== codeModel ? ` and ${liteModel} for chat replies` : ' (and chat)'}.” The watcher will verify and deliver it once.` : `Reply once in channel ${cid}${thread} (with your agent creds): "I'm on ${codeModel} for code work${liteModel !== codeModel ? ` and ${liteModel} for chat replies` : ' (and chat)'}.” One line. Then stop.`}`, [cid], null, delivery)
1297
1451
  } else if (mcmd.invalid) {
1298
- void drain('fast', `An engineer tried to switch your model to "${mcmd.invalid}", which isn't one you recognize. Reply once in channel ${cid}${thread} (with your agent creds): say you support "opus", "sonnet", "haiku", or a full "claude-…" id, and ask which they meant. One line. Then stop.`)
1452
+ const delivery = guardedDelivery('model')
1453
+ void drain('fast', `An engineer tried to switch your model to "${mcmd.invalid}", which isn't one you recognize. ${delivery ? 'Return one short final answer saying you support "opus", "sonnet", "haiku", or a full model id and asking which they meant. Do not call post_message; the watcher will verify and deliver it once.' : `Reply once in channel ${cid}${thread} (with your agent creds): say you support "opus", "sonnet", "haiku", or a full "claude-…" id, and ask which they meant. One line. Then stop.`}`, [cid], null, delivery)
1299
1454
  } else {
1300
1455
  const tgt = mcmd.target // 'chat' | 'code' | 'both'
1301
1456
  const prev = `code ${codeModel}/chat ${liteModel}`
@@ -1305,7 +1460,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1305
1460
  persistModel()
1306
1461
  const label = tgt === 'chat' ? 'chat model' : tgt === 'code' ? 'code model' : 'model'
1307
1462
  log('model switched (' + tgt + ') ' + prev + ' → code ' + codeModel + '/chat ' + liteModel + (who ? ' (by ' + who + ')' : ''))
1308
- void drain('fast', `An engineer switched your ${label} to "${mcmd.set}" — active for your next ${tgt === 'chat' ? 'chat replies' : tgt === 'code' ? 'code cycles' : 'actions'}. Post ONE short confirmation in channel ${cid}${thread} (with your agent creds): e.g. "Switched my ${label} to ${mcmd.set} — I'll use it from here." Then stop.`)
1463
+ const delivery = guardedDelivery('model')
1464
+ void drain('fast', `An engineer switched your ${label} to "${mcmd.set}" — active for your next ${tgt === 'chat' ? 'chat replies' : tgt === 'code' ? 'code cycles' : 'actions'}. ${delivery ? `Return one short final confirmation such as "Switched my ${label} to ${mcmd.set}. I'll use it from here." Do not call post_message; the watcher will verify and deliver it once.` : `Post ONE short confirmation in channel ${cid}${thread} (with your agent creds): e.g. "Switched my ${label} to ${mcmd.set} — I'll use it from here." Then stop.`}`, [cid], null, delivery)
1309
1465
  }
1310
1466
  return
1311
1467
  }
@@ -1314,16 +1470,24 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1314
1470
  // subsequent working/typing heartbeat for its lane.
1315
1471
  if (cid != null) sendStatus(cid, 'thinking')
1316
1472
  const codingMention = canCode && needsCode(text)
1473
+ const replyDelivery = guardedDelivery(codingMention ? 'result' : 'reply', !codingMention)
1317
1474
  const ctx = cid != null
1318
- ? `You were @mentioned in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". This mention is FOR YOU. ${codingMention ? 'This is repository work: complete the coding flow first, then send' : 'Send'} EXACTLY ONE reply with post_message: arguments: channel_id ${cid}${threadRoot != null ? `, parent_id ${threadRoot} (reply IN THAT THREAD, do not start a new top-level message)` : ''}, plus agent_identifier + agent_api_key from the AUTH line above, and a 1-3 sentence reply. Compose the whole answer, then post it ONCE. Do not post a first reply and then a revised version. FIRST read the recent messages in this thread: if you already answered this, or another agent was the one addressed, do NOT post at all. Be sure of your answer before sending.${who ? ` To @mention them back, write their EXACT full name "@${who}". A mention only links when the name matches exactly.` : ''} You ALREADY have the message here. Do not poll_inbox, and after your single reply, STOP.`
1475
+ ? replyDelivery
1476
+ ? `You were @mentioned in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". This mention is FOR YOU. ${codingMention ? 'Complete the repository work and verification first.' : 'Answer the request.'} Do NOT call post_message; it is intentionally unavailable. Return only the final 1-3 sentence reply as your final answer. The watcher will read the real thread, check its persistent memory graph, and render that answer at most once.${who ? ` To mention the requester, use their exact full name "@${who}".` : ''} Do not poll_inbox.`
1477
+ : `You were @mentioned in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". This mention is FOR YOU. ${codingMention ? 'This is repository work: complete the coding flow first, then send' : 'Send'} EXACTLY ONE reply with post_message: arguments: channel_id ${cid}${threadRoot != null ? `, parent_id ${threadRoot} (reply IN THAT THREAD, do not start a new top-level message)` : ''}, plus agent_identifier + agent_api_key from the AUTH line above, and a 1-3 sentence reply. Compose the whole answer, then post it ONCE. Do not post a first reply and then a revised version. FIRST read the recent messages in this thread: if you already answered this, or another agent was the one addressed, do NOT post at all. Be sure of your answer before sending.${who ? ` To @mention them back, write their EXACT full name "@${who}". A mention only links when the name matches exactly.` : ''} You ALREADY have the message here. Do not poll_inbox, and after your single reply, STOP.`
1319
1478
  : undefined
1320
1479
  if (codingMention) {
1321
- const ack = cid != null
1322
- ? `You were asked for repository work in channel ${cid}${threadRoot != null ? `, thread ${threadRoot}` : ''}. The dedicated work lane has accepted it. Post exactly one short reply with post_message in that same thread saying you have picked it up and will return there with the verified result. Include agent_identifier + agent_api_key. Do not inspect or edit code in this reply lane.`
1323
- : undefined
1324
- void drain('coord', ack, cid == null ? [] : [cid])
1325
- void drain('full', ctx, cid == null ? [] : [cid])
1326
- } else void drain('fast', ctx, cid == null ? [] : [cid])
1480
+ if (agent === 'codex' && cid != null) {
1481
+ const ack = `${who ? `@${who} ` : ''}I've picked this up and will return here with the verified result.`
1482
+ void postMessageOnce({ ...guardedDelivery('ack'), content: ack }).catch((e) => log('guarded acknowledgement failed closed: ' + (e?.message || e)))
1483
+ } else {
1484
+ const ack = cid != null
1485
+ ? `You were asked for repository work in channel ${cid}${threadRoot != null ? `, thread ${threadRoot}` : ''}. The dedicated work lane has accepted it. Post exactly one short reply with post_message in that same thread saying you have picked it up and will return there with the verified result. Include agent_identifier + agent_api_key. Do not inspect or edit code in this reply lane.`
1486
+ : undefined
1487
+ void drain('coord', ack, cid == null ? [] : [cid])
1488
+ }
1489
+ void drain('full', ctx, cid == null ? [] : [cid], null, replyDelivery)
1490
+ } else void drain('fast', ctx, cid == null ? [] : [cid], null, replyDelivery)
1327
1491
  } else if (k === 'error') {
1328
1492
  const detail = raw && (raw.message || raw.error || raw.reason || raw.code || raw.d?.message || raw.d?.error)
1329
1493
  log('error event: ' + (detail ? String(detail) : JSON.stringify(raw)).slice(0, 220))
@@ -1345,9 +1509,17 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1345
1509
  // Workspace ethics: a one-time hello the FIRST time this agent ever connects.
1346
1510
  const introMarker = join(OV_DIR, 'intro-' + slugify(identifier) + '.done')
1347
1511
  if (!existsSync(introMarker)) {
1348
- try { mkdirSync(OV_DIR, { recursive: true }); writeFileSync(introMarker, new Date().toISOString() + '\n') } catch { /* best-effort */ }
1512
+ if (agent !== 'codex') {
1513
+ try { mkdirSync(OV_DIR, { recursive: true }); writeFileSync(introMarker, new Date().toISOString() + '\n') } catch { /* best-effort */ }
1514
+ }
1349
1515
  log('first connection — introducing self to the workspace')
1350
- introTimer = setTimeout(() => void drain('intro'), 5000) // let the socket subscribe first
1516
+ introTimer = setTimeout(() => {
1517
+ if (agent !== 'codex') { void drain('intro'); return }
1518
+ void announceIntroduction().then((delivered) => {
1519
+ if (!delivered) return
1520
+ try { mkdirSync(OV_DIR, { recursive: true }); writeFileSync(introMarker, new Date().toISOString() + '\n') } catch { /* best-effort */ }
1521
+ }).catch((e) => log('guarded introduction failed: ' + (e?.message || e)))
1522
+ }, 5000) // let the socket subscribe first
1351
1523
  }
1352
1524
  // Reconciliation replaces the old model-driven startup/daily sweep. It uses
1353
1525
  // supported MCP tools directly, stays silent when empty, and hands verified