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 +11 -2
- package/bin/cli.mjs +40 -0
- package/package.json +1 -1
- package/scripts/certify.mjs +25 -2
- package/src/events.mjs +141 -0
- package/src/memory.mjs +82 -0
- package/src/pr-push.mjs +112 -0
- package/src/watch.mjs +422 -94
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,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(
|
|
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
|
-
['
|
|
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
|
+
}
|
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
|
+
}
|