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 +11 -2
- package/bin/cli.mjs +40 -0
- package/package.json +1 -1
- package/scripts/certify.mjs +16 -1
- package/src/events.mjs +62 -3
- package/src/memory.mjs +82 -0
- package/src/pr-push.mjs +112 -0
- package/src/watch.mjs +218 -46
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 +
|
|
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.
|
|
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
package/scripts/certify.mjs
CHANGED
|
@@ -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(
|
|
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
|
+
}
|
package/src/pr-push.mjs
ADDED
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
886
|
-
|
|
887
|
-
|
|
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
|
-
|
|
980
|
-
|
|
981
|
-
|
|
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({
|
|
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
|
|
1026
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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) {
|
|
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
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
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
|
-
|
|
1322
|
-
|
|
1323
|
-
:
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
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
|
-
|
|
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(() =>
|
|
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
|