openvisio-agent 0.17.6 → 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.17.6",
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,8 +21,13 @@ 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')
30
+ const taskHook = readFileSync(join(repo, 'frontend', 'hooks', 'useBackendTasks.ts'), 'utf8')
26
31
  const spec = readFileSync(join(repo, 'docs', 'CODEX_BYO_AGENT_SPEC.md'), 'utf8')
27
32
 
28
33
  const assertions = [
@@ -30,10 +35,19 @@ const assertions = [
30
35
  ['task signals are verified with get_ticket', watcher.includes("callMcpTool('get_ticket'")],
31
36
  ['review and testing handoffs do not restart work', watcher.includes('taskIsAwaitingReview(task, reviewIds)') && watcher.includes('taskIsAwaitingReview(ticket)')],
32
37
  ['review handoff releases the task key for future rework', watcher.includes('seenTasks.delete(taskKey)') && watcher.includes('seenTasks.delete(key)')],
33
- ['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}`')],
34
39
  ['completion requires review/done state and PR evidence', watcher.includes('buildTaskCompletionReport') && watcher.includes('completion report deferred')],
35
40
  ['completion delivery survives reconnect and deduplicates', watcher.includes('pendingCompletionReports: [...pendingCompletionReports]') && watcher.includes('reportedCompletions: [...reportedCompletions]') && watcher.includes('reportedCompletions.has(report.key)')],
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)')],
36
42
  ['ticket comments cannot masquerade as channel completion', watcher.includes('didChannelMessage') && watcher.includes("mcpCalls.includes('post_message')")],
43
+ ['single-watcher acquisition is atomic and fails closed', watcher.includes("openSync(lockPath, 'wx')") && watcher.includes('Could not acquire the single-watcher lock')],
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)')],
37
51
  ['two internal lanes exist', watcher.includes('work: createCycleRunner') && watcher.includes('reply: createCycleRunner')],
38
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')")],
39
53
  ['assigned task activity resolves a project channel', watcher.includes("callMcpTool('list_channels'") && watcher.includes('projectStatusChannel(projectId)')],
@@ -44,10 +58,19 @@ const assertions = [
44
58
  ['frontend consumes working event', activityHook.includes("'channel:agent:working'")],
45
59
  ['frontend consumes typing event', activityHook.includes("'channel:agent:typing'")],
46
60
  ['frontend activity TTL distinguishes work from typing', activityHook.includes('thinking: 6_000') && activityHook.includes('typing: 5_000') && activityHook.includes('working: 30_000')],
61
+ ['frontend consumes documented task comment events', taskHook.includes("'task:comment':") && taskHook.includes("'task:comment_updated':") && taskHook.includes("'task:comment_deleted':") && taskHook.includes("'task:comment_reacted':")],
47
62
  ['completion has an evidence failure gate', watcher.includes('WORK_CYCLE_FAILED')],
48
- ['Codex policy rejection is captured from stderr', watcher.includes("stdio: ['ignore', 'pipe', 'pipe']") && watcher.includes('inspectDiagnostic(d)')],
63
+ ['OpenCode emits structured events for runtime evidence', watcher.includes("'--format', 'json'") && watcher.includes('opencodeEventEvidence(event)')],
64
+ ['OpenCode acknowledgements cannot satisfy coding completion', watcher.includes("agent === 'codex' || agent === 'opencode'") && watcher.includes('releaseTaskForRetry(activeTaskRef, prompt)')],
65
+ ['Codex policy rejection is captured from stderr', watcher.includes("stdio: ['ignore', 'pipe', 'pipe']") && watcher.includes('forwardDiagnostic(d)') && watcher.includes('inspectDiagnostic(incoming)')],
66
+ ['Codex recoverable subprocess diagnostics are not surfaced as activity', watcher.includes('shouldSuppressCodexDiagnostic(line)') && watcher.includes("forwardDiagnostic('', true)") && watcher.includes('RECOVER DEAD COMMAND SESSIONS')],
49
67
  ['policy rejection cannot be logged as successful', watcher.includes("subtype = policyBlock ? 'blocked'")],
50
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'")],
51
74
  ['policy blocker is surfaced to the user', watcher.includes('reportPolicyBlock(prompt, activeTaskRef, result.policyBlock)') && watcher.includes("Action required: I'm blocked")],
52
75
  ['blocker routing carries explicit task identity', watcher.includes('taskRefs: []') && watcher.includes('activeTaskRef') && watcher.includes('taskRef: activeTaskRef')],
53
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
@@ -65,6 +65,57 @@ export function buildTaskCompletionReport(task, { projectId, fallbackText = '' }
65
65
  return { key: `${projectId ?? task.project_id ?? task.projectId ?? '?'}:${task.id}:${revision}`, content, prUrl }
66
66
  }
67
67
 
68
+ // OpenCode's `run --format json` emits one JSON object per completed text/tool
69
+ // part. Reduce each event to the small set of facts the watcher is allowed to
70
+ // trust. In particular, model prose and process exit 0 are never work evidence.
71
+ export function opencodeEventEvidence(event) {
72
+ if (!event || typeof event !== 'object') return {}
73
+ const part = event.part ?? event.properties?.part ?? event.item ?? event
74
+ if (!part || typeof part !== 'object') return {}
75
+
76
+ const eventType = String(event.type ?? '')
77
+ const partType = String(part.type ?? '')
78
+ if (eventType === 'text' || partType === 'text') {
79
+ return { outputText: typeof part.text === 'string' ? part.text : '' }
80
+ }
81
+ if (eventType === 'error') {
82
+ return { runtimeError: String(event.error?.message ?? event.message ?? part.error ?? 'OpenCode runtime error') }
83
+ }
84
+ if (eventType !== 'tool_use' && partType !== 'tool') return {}
85
+
86
+ const tool = String(part.tool ?? part.name ?? event.tool ?? '').trim()
87
+ if (!tool) return {}
88
+ const state = part.state && typeof part.state === 'object' ? part.state : {}
89
+ const status = String(state.status ?? part.status ?? '').toLowerCase()
90
+ const failed = /error|failed|denied|rejected/.test(status) || state.error != null || part.error != null
91
+ const completed = !failed && (!status || /completed|success|succeeded|ok/.test(status))
92
+ const input = state.input && typeof state.input === 'object' ? state.input : (part.input && typeof part.input === 'object' ? part.input : {})
93
+ const command = String(input.command ?? input.cmd ?? '')
94
+
95
+ const lowerTool = tool.toLowerCase()
96
+ const prefixed = /^(?:mcp__)?openvisio(?:-team|_team)(?:__|[_.:/-])(.+)$/i.exec(tool)
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|list_codebases|create_codebase_branch|create_codebase_commit|write_codebase_file|create_pull_request)$/
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)
101
+ const mutationTool = /^(?:edit|write|patch|apply_patch|multiedit|multi_edit)$/i.test(tool)
102
+ const bashTool = /^(?:bash|shell|terminal|exec|command)$/i.test(tool)
103
+ const commandMutation = /\b(?:git\s+(?:commit|push)|gh\s+pr\s+create)\b/i.test(command)
104
+
105
+ return {
106
+ tool,
107
+ ...(mcpTool ? { mcpTool } : {}),
108
+ failed,
109
+ completed,
110
+ didCode: completed && (mutationTool || bashTool || codebaseMutation),
111
+ didRepoMutation: completed && (mutationTool || codebaseMutation || (bashTool && commandMutation)),
112
+ didMcpTaskRead: completed && /^(?:get_ticket|list_tasks|list_task_types)$/.test(mcpTool),
113
+ didMcpTaskUpdate: completed && mcpTool === 'update_ticket',
114
+ didMessage: completed && /^(?:post_message|comment_ticket)$/.test(mcpTool),
115
+ didChannelMessage: completed && mcpTool === 'post_message',
116
+ }
117
+ }
118
+
68
119
  export function agentStateRequest(backend, channelId, state, apiKey, identifier) {
69
120
  if (!['thinking', 'working', 'typing'].includes(state)) throw new Error('invalid agent state')
70
121
  const id = Number(channelId)
@@ -81,6 +132,16 @@ export function agentStateRequest(backend, channelId, state, apiKey, identifier)
81
132
 
82
133
  export function codexPolicyBlock(value) {
83
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
+ }
84
145
  if (!/rejected due to unacceptable risk|action was rejected due to unacceptable risk|explicitly approves? the action/i.test(text)) return null
85
146
  const command = /exec_command failed for [`']([^`']+)[`']/.exec(text)?.[1] || ''
86
147
  const reasonTail = text.split(/Reason:\s*/i)[1] || ''
@@ -90,6 +151,86 @@ export function codexPolicyBlock(value) {
90
151
  return { kind: 'authorization-required', command, reason: reason.slice(0, 900), commit, remote: push?.[1] || '', branch: push?.[2] || '' }
91
152
  }
92
153
 
154
+ // Codex emits this plumbing notice whenever `exec` sees non-interactive stdin,
155
+ // even when stdin is intentionally closed and there is nothing to read. It is
156
+ // not agent activity, progress, or a blocker, so keep it out of watcher logs.
157
+ export function shouldSuppressCodexDiagnostic(value) {
158
+ const line = String(value || '').trim()
159
+ if (/^Reading additional input from stdin\.\.\.$/.test(line)) return true
160
+
161
+ // `codex exec` can emit these after it has already recovered: the watcher
162
+ // pins the requested model, so a background catalog-refresh timeout does not
163
+ // change the active cycle, and an unknown write_stdin pid means that one
164
+ // command session exited before Codex polled it. The model still receives the
165
+ // tool error and can start a fresh command; these internals should not masquerade
166
+ // as agent activity or an OpenVisio work failure in the user's watcher log.
167
+ if (/\bERROR codex_models_manager::manager: failed to refresh available models: timeout waiting for child process to exit$/.test(line)) return true
168
+ return /\bERROR codex_core::tools::router: error=write_stdin failed: Unknown process id \d+$/.test(line)
169
+ }
170
+
171
+ // Build both a durable id key and a short-lived content signature for a mention.
172
+ // The backend can surface one logical message through WebSocket delivery and
173
+ // activity reconciliation with different envelope ids; the signature closes that
174
+ // gap without permanently suppressing a genuinely repeated question later.
175
+ export function mentionDedupeKeys(message, channelId) {
176
+ const m = message && typeof message === 'object' ? message : {}
177
+ const id = m.message_id ?? m.messageId ?? m.id
178
+ const parent = m.parent_id ?? m.parentId ?? ''
179
+ const text = String(m.content ?? m.body ?? m.text ?? m.message ?? '').replace(/\s+/g, ' ').trim().slice(0, 180)
180
+ return {
181
+ idKey: id != null ? `id:${id}` : '',
182
+ signatureKey: text ? `sig:${channelId ?? '?'}|${parent}|${text}` : '',
183
+ }
184
+ }
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
+
93
234
  // A mention event means this agent's name appeared somewhere, not necessarily
94
235
  // that the request was addressed to it. Reject a later-agent hand-off before a
95
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
+ }