openvisio-agent 0.19.12 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -4
- package/USER_GUIDE.md +174 -0
- package/bin/cli.mjs +9 -0
- package/package.json +7 -3
- package/scenarios/index.mjs +16 -0
- package/scenarios/routing.scenarios.mjs +348 -0
- package/scenarios/runtime.scenarios.mjs +451 -0
- package/scenarios/transport.scenarios.mjs +407 -0
- package/scenarios/workspace.scenarios.mjs +324 -0
- package/scripts/certify.mjs +9 -6
- package/scripts/run-scenarios.mjs +32 -0
- package/src/agent-journal.mjs +138 -0
- package/src/assignment-routing.mjs +19 -7
- package/src/authorization-resume.mjs +1 -1
- package/src/channel-routing.mjs +15 -8
- package/src/codex-mcp-proxy.mjs +63 -18
- package/src/concurrency.mjs +5 -3
- package/src/cycle-queue.mjs +16 -3
- package/src/events.mjs +89 -38
- package/src/mastra-harness.mjs +128 -22
- package/src/mcp-http.mjs +72 -19
- package/src/memory.mjs +15 -2
- package/src/pr-push.mjs +21 -8
- package/src/runner-pool.mjs +14 -3
- package/src/studio-cli.mjs +60 -0
- package/src/studio-server.mjs +224 -0
- package/src/task-types.mjs +3 -2
- package/src/thread-context.mjs +38 -3
- package/src/watch.mjs +345 -116
- package/src/ws.mjs +20 -1
- package/studio/app.mjs +658 -0
- package/studio/guide.html +122 -0
- package/studio/index.html +61 -0
- package/studio/style.css +223 -0
package/src/mastra-harness.mjs
CHANGED
|
@@ -12,18 +12,31 @@ const MCP_TOOL_NAMES = [
|
|
|
12
12
|
'update_ticket', 'list_channels', 'list_message_thread', 'list_activity',
|
|
13
13
|
'post_message', 'react_message', 'list_codebases', 'get_codebase',
|
|
14
14
|
'codebase_tree', 'create_codebase_branch', 'create_codebase_commit',
|
|
15
|
-
'create_pull_request', 'write_codebase_file', 'comment_ticket',
|
|
15
|
+
'create_pull_request', 'write_codebase_file', 'create_task_comment', 'comment_ticket',
|
|
16
16
|
]
|
|
17
17
|
const CHAT_SAFE_MCP_READS = new Set([
|
|
18
18
|
'list_agents', 'list_projects', 'list_tasks', 'list_task_types', 'get_ticket',
|
|
19
19
|
'list_channels', 'list_message_thread', 'list_activity', 'list_codebases',
|
|
20
20
|
'get_codebase', 'codebase_tree',
|
|
21
21
|
])
|
|
22
|
-
const COORDINATION_TOOLS = ['update_ticket', 'post_message', 'react_message', 'comment_ticket']
|
|
22
|
+
const COORDINATION_TOOLS = ['update_ticket', 'post_message', 'react_message', 'create_task_comment', 'comment_ticket']
|
|
23
23
|
|
|
24
24
|
const clean = (value, max = 300) => String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, max)
|
|
25
25
|
const json = (value) => { try { return JSON.stringify(value) } catch { return String(value ?? '') } }
|
|
26
26
|
|
|
27
|
+
// ACP model negotiation has no AbortSignal parameter. Race every remote wait,
|
|
28
|
+
// including a stream that stops cooperating, against the cycle's deadline.
|
|
29
|
+
async function abortable(operation, signal) {
|
|
30
|
+
signal.throwIfAborted()
|
|
31
|
+
let onAbort
|
|
32
|
+
const canceled = new Promise((_, reject) => {
|
|
33
|
+
onAbort = () => reject(signal.reason || new Error('ACP cycle canceled'))
|
|
34
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
35
|
+
})
|
|
36
|
+
try { return await Promise.race([Promise.resolve().then(operation), canceled]) }
|
|
37
|
+
finally { signal.removeEventListener('abort', onAbort) }
|
|
38
|
+
}
|
|
39
|
+
|
|
27
40
|
function commandFor(agent) {
|
|
28
41
|
if (agent === 'opencode') return { command: onPath('opencode') || 'opencode', args: ['acp'] }
|
|
29
42
|
if (agent === 'codex') {
|
|
@@ -66,7 +79,8 @@ export function acpPermissionResponse(request, { canCode = false, canCoordinate
|
|
|
66
79
|
// accepting an opaque MCP approval cannot grant a mutation or local access.
|
|
67
80
|
const allow = canCode || CHAT_SAFE_MCP_READS.has(name) || (canCoordinate && COORDINATION_TOOLS.includes(name)) || (opaqueMcpApproval && proxyProtected)
|
|
68
81
|
const preferred = allow ? 'allow_once' : 'reject_once'
|
|
69
|
-
const
|
|
82
|
+
const validOptions = options.filter((option) => option && typeof option.optionId === 'string' && option.optionId && ['allow_once', 'allow_always', 'reject_once', 'reject_always'].includes(option.kind))
|
|
83
|
+
const selected = validOptions.find((option) => option.kind === preferred) || validOptions.find((option) => option.kind === (allow ? 'allow_always' : 'reject_always'))
|
|
70
84
|
return selected ? { outcome: { outcome: 'selected', optionId: selected.optionId } } : { outcome: { outcome: 'cancelled' } }
|
|
71
85
|
}
|
|
72
86
|
|
|
@@ -78,24 +92,62 @@ function contentText(content) {
|
|
|
78
92
|
}
|
|
79
93
|
|
|
80
94
|
export function createMastraAcpRunner({
|
|
81
|
-
agent, mcpUrl, mcpHeaders = {}, workdir, canCode = !!workdir, canCoordinate = false, maxCycleMs,
|
|
82
|
-
log = () => {}, debug = false, model, onTool, systemPrompt = '', AcpAgentClass = AcpAgent,
|
|
95
|
+
agent, mcpUrl, mcpHeaders = {}, workdir, canCode = !!workdir, canCoordinate = false, maxCycleMs = 20 * 60_000,
|
|
96
|
+
log = () => {}, debug = false, model, onTool, onEvent, systemPrompt = '', AcpAgentClass = AcpAgent,
|
|
83
97
|
}) {
|
|
84
98
|
const runtime = commandFor(agent)
|
|
85
99
|
if (!runtime) return null
|
|
100
|
+
if (!Number.isFinite(maxCycleMs) || maxCycleMs <= 0 || maxCycleMs > 2_147_483_647) throw new Error('ACP maxCycleMs must be a positive supported timeout')
|
|
86
101
|
let active = null
|
|
87
102
|
let persistent = null
|
|
88
103
|
let persistentKey = ''
|
|
89
104
|
let sessionHasPrompted = false
|
|
90
105
|
let negotiatedRequested = ''
|
|
91
106
|
let negotiatedSelected = ''
|
|
107
|
+
let persistentSessionToken = null
|
|
108
|
+
let lastProcessEvent = null
|
|
109
|
+
const disconnected = new WeakSet()
|
|
92
110
|
const defaultCwd = workdir || process.cwd()
|
|
111
|
+
const redact = (value) => Object.values(mcpHeaders).filter(Boolean).reduce((safe, secret) => safe.split(String(secret)).join('[redacted]'), String(value))
|
|
112
|
+
const notify = (type, data) => {
|
|
113
|
+
try { Promise.resolve(onEvent?.(type, data)).catch(() => {}) } catch { /* diagnostics cannot block work */ }
|
|
114
|
+
}
|
|
115
|
+
const disconnect = (acp, status = 'disconnected') => {
|
|
116
|
+
acp.connection.disconnect()
|
|
117
|
+
if (!disconnected.has(acp)) {
|
|
118
|
+
disconnected.add(acp)
|
|
119
|
+
lastProcessEvent?.('process.stopped', { status })
|
|
120
|
+
}
|
|
121
|
+
}
|
|
93
122
|
|
|
94
123
|
async function runCycle(prompt, cycleModel, cycleOptions = {}) {
|
|
124
|
+
if (active) throw new Error('ACP session already has an active cycle')
|
|
95
125
|
const cycleCwd = cycleOptions.workdir || defaultCwd
|
|
96
126
|
const controller = new AbortController()
|
|
97
127
|
let timedOut = false
|
|
98
128
|
const startedAt = performance.now()
|
|
129
|
+
const cycleId = cycleOptions.cycleId ?? null
|
|
130
|
+
const emit = (type, data = {}) => notify(type, { cycleId, attempt: cycleOptions.attempt || 1, provider: agent, runtime: 'mastra-acp', workdir: cycleCwd, watcherPid: process.pid, runtimePid: null, ...data })
|
|
131
|
+
const progress = new Map()
|
|
132
|
+
const flushProgress = () => {
|
|
133
|
+
for (const [messageId, entry] of progress) {
|
|
134
|
+
if (entry.sent === entry.text) continue
|
|
135
|
+
emit('output.progress', { messageId, text: entry.text })
|
|
136
|
+
entry.sent = entry.text
|
|
137
|
+
entry.emittedAt = performance.now()
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const observeCommentary = (update) => {
|
|
141
|
+
const messageId = clean(update.messageId || 'commentary', 200)
|
|
142
|
+
let entry = progress.get(messageId)
|
|
143
|
+
if (!entry) {
|
|
144
|
+
if (progress.size >= 16) { flushProgress(); progress.delete(progress.keys().next().value) }
|
|
145
|
+
entry = { text: '', sent: '', emittedAt: 0 }
|
|
146
|
+
progress.set(messageId, entry)
|
|
147
|
+
}
|
|
148
|
+
entry.text = redact(entry.text + contentText(update.content)).slice(-8000)
|
|
149
|
+
if (!entry.sent || performance.now() - entry.emittedAt >= 100) flushProgress()
|
|
150
|
+
}
|
|
99
151
|
const before = canCode ? repositorySnapshot(cycleCwd) : Promise.resolve(null)
|
|
100
152
|
let firstEventMs = null
|
|
101
153
|
let policyBlock = null
|
|
@@ -126,7 +178,9 @@ export function createMastraAcpRunner({
|
|
|
126
178
|
}] : [{ type: 'http', name: 'openvisio-team', url: mcpUrl, headers }]
|
|
127
179
|
const sessionKey = JSON.stringify([cycleCwd, disabledMcpTools, mcpServers])
|
|
128
180
|
if (!persistent || persistentKey !== sessionKey) {
|
|
129
|
-
persistent
|
|
181
|
+
if (persistent) disconnect(persistent)
|
|
182
|
+
const sessionToken = {}
|
|
183
|
+
persistentSessionToken = sessionToken
|
|
130
184
|
persistent = new AcpAgentClass({
|
|
131
185
|
id: `openvisio-${agent}-${Date.now()}`,
|
|
132
186
|
name: `OpenVisio ${agent}`,
|
|
@@ -155,11 +209,21 @@ export function createMastraAcpRunner({
|
|
|
155
209
|
},
|
|
156
210
|
persistSession: true,
|
|
157
211
|
onPermissionRequest: async (request) => acpPermissionResponse(request, { canCode, canCoordinate, proxyProtected }),
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
212
|
+
// Filter Codex's explicit commentary phase before Mastra converts
|
|
213
|
+
// agent_message_chunk into plain text and discards its phase metadata.
|
|
214
|
+
// The final result must not include earlier progress announcements.
|
|
215
|
+
// Unannotated output remains compatible with older ACP adapters.
|
|
161
216
|
createClient: agent === 'codex' ? (client) => new Proxy(client, {
|
|
162
217
|
get(target, property, receiver) {
|
|
218
|
+
if (property === 'sessionUpdate') return async (notification) => {
|
|
219
|
+
const update = notification?.update
|
|
220
|
+
if (update?.sessionUpdate === 'agent_message_chunk' && update?._meta?.codex?.phase === 'commentary') {
|
|
221
|
+
if (active?.sessionToken === sessionToken && (!active.acp.connection.sessionId || active.acp.connection.sessionId === notification.sessionId)) active.observeCommentary(update)
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
return target.sessionUpdate(notification)
|
|
225
|
+
}
|
|
226
|
+
// Account-status extensions are intentionally silent in a watcher.
|
|
163
227
|
if (property === 'extNotification') return async () => {}
|
|
164
228
|
return Reflect.get(target, property, receiver)
|
|
165
229
|
},
|
|
@@ -171,9 +235,11 @@ export function createMastraAcpRunner({
|
|
|
171
235
|
negotiatedSelected = ''
|
|
172
236
|
}
|
|
173
237
|
const acp = persistent
|
|
174
|
-
active = { acp, controller }
|
|
238
|
+
active = { acp, controller, observeCommentary, sessionToken: persistentSessionToken }
|
|
239
|
+
lastProcessEvent = emit
|
|
240
|
+
emit('process.started', { status: acp.connection.sessionId ? 'reused' : 'connecting', sessionId: acp.connection.sessionId || null })
|
|
175
241
|
const invalidate = () => {
|
|
176
|
-
|
|
242
|
+
disconnect(acp)
|
|
177
243
|
if (persistent === acp) {
|
|
178
244
|
persistent = null
|
|
179
245
|
persistentKey = ''
|
|
@@ -188,10 +254,10 @@ export function createMastraAcpRunner({
|
|
|
188
254
|
try {
|
|
189
255
|
if (requestedModel) {
|
|
190
256
|
if (requestedModel !== negotiatedRequested) {
|
|
191
|
-
const availableModels = await acp.getAvailableModels()
|
|
257
|
+
const availableModels = await abortable(() => acp.getAvailableModels(), controller.signal)
|
|
192
258
|
const resolution = resolveAvailableModel(requestedModel, availableModels)
|
|
193
259
|
if (resolution.selected) {
|
|
194
|
-
await acp.setModel(resolution.selected)
|
|
260
|
+
await abortable(() => acp.setModel(resolution.selected), controller.signal)
|
|
195
261
|
selectedModel = resolution.selected
|
|
196
262
|
if (selectedModel !== requestedModel) log(`model ${requestedModel} resolved to available ${selectedModel} [${resolution.reason}]`)
|
|
197
263
|
} else {
|
|
@@ -206,35 +272,65 @@ export function createMastraAcpRunner({
|
|
|
206
272
|
log(`running ${agent} cycle via Mastra ACP…${selectedModel || requestedModel ? ` [${selectedModel || requestedModel}]` : ' [runtime default]'}`)
|
|
207
273
|
const fullPrompt = systemPrompt && !sessionHasPrompted ? `${systemPrompt}\n\n${prompt}` : prompt
|
|
208
274
|
sessionHasPrompted = true
|
|
209
|
-
|
|
275
|
+
const stream = acp.connection.promptStream(fullPrompt, controller.signal)[Symbol.asyncIterator]()
|
|
276
|
+
let readyReported = false
|
|
277
|
+
while (true) {
|
|
278
|
+
const next = await abortable(() => stream.next(), controller.signal)
|
|
279
|
+
if (next.done) break
|
|
280
|
+
const event = next.value
|
|
281
|
+
if (!readyReported) {
|
|
282
|
+
readyReported = true
|
|
283
|
+
emit('process.ready', { status: 'running', model: selectedModel || requestedModel || null, sessionId: acp.connection.sessionId || null })
|
|
284
|
+
}
|
|
210
285
|
firstEventMs ??= Math.round(performance.now() - startedAt)
|
|
211
286
|
if (event.type === 'text') { outputText += event.text; continue }
|
|
212
287
|
const update = event.update || {}
|
|
288
|
+
if (update.sessionUpdate === 'plan') {
|
|
289
|
+
const entries = (Array.isArray(update.entries) ? update.entries : []).slice(0, 100).map((entry) => ({
|
|
290
|
+
content: clean(redact(entry?.content || ''), 2000),
|
|
291
|
+
status: ['pending', 'in_progress', 'completed'].includes(entry?.status) ? entry.status : 'unknown',
|
|
292
|
+
priority: ['low', 'medium', 'high'].includes(entry?.priority) ? entry.priority : undefined,
|
|
293
|
+
})).filter((entry) => entry.content)
|
|
294
|
+
emit('plan.updated', { entries })
|
|
295
|
+
continue
|
|
296
|
+
}
|
|
213
297
|
if (update.sessionUpdate === 'agent_message_chunk') {
|
|
298
|
+
if (update?._meta?.codex?.phase === 'commentary') { observeCommentary(update); continue }
|
|
214
299
|
outputText += contentText(update.content)
|
|
215
300
|
continue
|
|
216
301
|
}
|
|
217
302
|
if (!['tool_call', 'tool_call_update'].includes(update.sessionUpdate)) continue
|
|
218
303
|
const prior = tools.get(update.toolCallId) || {}
|
|
219
|
-
|
|
304
|
+
// Metadata-only updates after completion cannot move an earlier tool
|
|
305
|
+
// past a later result message and rewrite the observed execution order.
|
|
306
|
+
const order = prior.status === 'completed' && (!update.status || update.status === 'completed') ? prior.order : ++toolSequence
|
|
307
|
+
const merged = { ...prior, ...update, order }
|
|
220
308
|
tools.set(update.toolCallId, merged)
|
|
221
309
|
const name = toolName(merged)
|
|
222
310
|
const label = name || clean(merged.title, 100) || 'tool'
|
|
311
|
+
const terminal = ['completed', 'failed'].includes(merged.status)
|
|
312
|
+
const eventType = terminal && prior.status !== merged.status ? 'tool.finished' : prior.toolCallId ? 'tool.updated' : 'tool.started'
|
|
313
|
+
const locations = (Array.isArray(merged.locations) ? merged.locations : []).slice(0, 30).map((location) => ({
|
|
314
|
+
path: clean(redact(location?.path || ''), 1000),
|
|
315
|
+
...(Number.isInteger(location?.line) ? { line: location.line } : {}),
|
|
316
|
+
})).filter((location) => location.path)
|
|
317
|
+
emit(eventType, { toolCallId: clean(merged.toolCallId, 200), name: clean(redact(label), 300), title: clean(redact(merged.title || label), 2000), status: merged.status || 'pending', kind: clean(merged.kind, 100), locations })
|
|
223
318
|
try { onTool?.(label) } catch { /* status is best-effort */ }
|
|
224
319
|
if (debug && update.sessionUpdate === 'tool_call') log(` → tool ${label}`)
|
|
225
320
|
if (name) {
|
|
226
321
|
calls.add(name)
|
|
227
|
-
if (merged.status === 'failed') errors.set(name, clean(json(merged.rawOutput) || merged.title))
|
|
322
|
+
if (merged.status === 'failed') errors.set(name, clean(redact(json(merged.rawOutput) || merged.title)))
|
|
228
323
|
else if (merged.status === 'completed') errors.delete(name)
|
|
229
324
|
}
|
|
230
325
|
}
|
|
326
|
+
controller.signal.throwIfAborted()
|
|
231
327
|
// Only final, completed tool states are evidence. Started/failed edits and
|
|
232
328
|
// board mutations cannot stand in for completed repository work.
|
|
233
329
|
for (const tool of [...tools.values()].sort((a, b) => a.order - b.order)) {
|
|
234
330
|
const name = toolName(tool)
|
|
235
331
|
if (tool.status === 'failed') policyBlock ||= codexPolicyBlock(json(tool.rawOutput))
|
|
236
332
|
if (tool.status !== 'completed') continue
|
|
237
|
-
didMessage ||=
|
|
333
|
+
didMessage ||= ['post_message', 'create_task_comment', 'comment_ticket'].includes(name)
|
|
238
334
|
didChannelMessage ||= name === 'post_message'
|
|
239
335
|
didResultMessage ||= name === 'post_message' && didRepoMutation
|
|
240
336
|
didMcpTaskRead ||= ['list_tasks', 'get_ticket'].includes(name)
|
|
@@ -248,10 +344,12 @@ export function createMastraAcpRunner({
|
|
|
248
344
|
}
|
|
249
345
|
const beforeState = await before
|
|
250
346
|
const after = canCode ? await repositorySnapshot(cycleCwd) : null
|
|
347
|
+
controller.signal.throwIfAborted()
|
|
251
348
|
if (beforeState != null && after != null && beforeState !== after) { didCode = true; didRepoMutation = true }
|
|
252
|
-
const redact = (value) => Object.values(mcpHeaders).filter(Boolean).reduce((safe, secret) => safe.split(String(secret)).join('[redacted]'), String(value))
|
|
253
349
|
for (const [name, detail] of errors) log(`MCP ${name} failed: ${redact(detail)}`)
|
|
254
350
|
log(`${agent} cycle done via Mastra ACP (${errors.size ? 'tool-errors' : 'ok'})`)
|
|
351
|
+
flushProgress()
|
|
352
|
+
if (outputText.trim()) emit('output.final', { text: redact(outputText.trim()).slice(0, 8000), status: policyBlock ? 'blocked' : 'ok' })
|
|
255
353
|
return {
|
|
256
354
|
type: 'result', subtype: policyBlock ? 'blocked' : 'ok', policyBlock, timings: { firstEventMs, totalMs: Math.round(performance.now() - startedAt) }, runtime: 'mastra-acp', model: selectedModel || null, requestedModel: requestedModel || null, outputText: outputText.trim(),
|
|
257
355
|
mcpCalls: [...calls], mcpErrors: [...errors.keys()], mcpErrorDetails: Object.fromEntries(errors),
|
|
@@ -262,7 +360,10 @@ export function createMastraAcpRunner({
|
|
|
262
360
|
const canceled = controller.signal.aborted
|
|
263
361
|
const subtype = timedOut ? 'timeout' : canceled ? 'canceled' : 'error'
|
|
264
362
|
const errorDetail = [error?.message, error?.data ? json(error.data) : ''].filter(Boolean).join(': ')
|
|
265
|
-
const safeError =
|
|
363
|
+
const safeError = redact(errorDetail)
|
|
364
|
+
flushProgress()
|
|
365
|
+
if (outputText.trim()) emit('output.final', { text: redact(outputText.trim()).slice(0, 8000), status: subtype })
|
|
366
|
+
emit('process.error', { status: subtype, summary: clean(safeError, 2000) })
|
|
266
367
|
log(`${agent} cycle done via Mastra ACP (${subtype}${!canceled && safeError ? ': ' + clean(safeError, 1200) : ''})`)
|
|
267
368
|
invalidate()
|
|
268
369
|
return {
|
|
@@ -272,6 +373,8 @@ export function createMastraAcpRunner({
|
|
|
272
373
|
didMcpTaskRead, didMcpTaskUpdate,
|
|
273
374
|
}
|
|
274
375
|
} finally {
|
|
376
|
+
flushProgress()
|
|
377
|
+
if (!disconnected.has(acp)) emit('process.ready', { status: 'idle', sessionId: acp.connection.sessionId || null })
|
|
275
378
|
clearTimeout(timer)
|
|
276
379
|
if (active?.acp === acp) active = null
|
|
277
380
|
}
|
|
@@ -281,8 +384,10 @@ export function createMastraAcpRunner({
|
|
|
281
384
|
if (!active) return
|
|
282
385
|
const { acp, controller } = active
|
|
283
386
|
controller.abort()
|
|
284
|
-
|
|
285
|
-
|
|
387
|
+
// The canceled cycle must release its lane even if ACP never acknowledges
|
|
388
|
+
// cancel. Disconnect terminates the transport and its pending requests.
|
|
389
|
+
try { Promise.resolve(acp.connection.cancel()).catch(() => {}) } catch { /* already stopped */ }
|
|
390
|
+
disconnect(acp, 'canceled')
|
|
286
391
|
if (persistent === acp) {
|
|
287
392
|
persistent = null
|
|
288
393
|
persistentKey = ''
|
|
@@ -293,7 +398,8 @@ export function createMastraAcpRunner({
|
|
|
293
398
|
}
|
|
294
399
|
|
|
295
400
|
const close = () => {
|
|
296
|
-
|
|
401
|
+
active?.controller.abort()
|
|
402
|
+
if (persistent) disconnect(persistent)
|
|
297
403
|
persistent = null
|
|
298
404
|
persistentKey = ''
|
|
299
405
|
sessionHasPrompted = false
|
package/src/mcp-http.mjs
CHANGED
|
@@ -1,8 +1,51 @@
|
|
|
1
|
+
const selectResponse = (messages, id) => {
|
|
2
|
+
const responses = messages.flat().filter((message) => message && typeof message === 'object' && !Array.isArray(message) && !message.method)
|
|
3
|
+
const exact = responses.find((message) => message.id === id)
|
|
4
|
+
if (exact) return exact
|
|
5
|
+
// Older stateless adapters omit the JSON-RPC envelope. Keep supporting them,
|
|
6
|
+
// but never mistake a response to a different request for this one.
|
|
7
|
+
return responses.find((message) => message.id == null) || null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const parseBody = (body, id) => {
|
|
11
|
+
let messages
|
|
12
|
+
try {
|
|
13
|
+
if (/^\s*[\[{]/.test(body)) messages = [JSON.parse(body)]
|
|
14
|
+
else {
|
|
15
|
+
messages = body.split(/\r?\n\r?\n/).map((event) => event.split(/\r?\n/)
|
|
16
|
+
.filter((line) => line.startsWith('data:')).map((line) => line.slice(5).replace(/^ /, '')).join('\n'))
|
|
17
|
+
.filter(Boolean).map((data) => JSON.parse(data))
|
|
18
|
+
if (!messages.length && !body.trim()) messages = [{}]
|
|
19
|
+
}
|
|
20
|
+
} catch { throw new Error('MCP returned a non-JSON response') }
|
|
21
|
+
return selectResponse(messages, id)
|
|
22
|
+
}
|
|
23
|
+
|
|
1
24
|
const parsePayload = async (res) => {
|
|
2
|
-
const
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
25
|
+
const payload = parseBody(await res.text(), res.requestId)
|
|
26
|
+
if (!payload) throw new Error('MCP returned no matching JSON-RPC response')
|
|
27
|
+
return payload
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const readBody = async (res, id) => {
|
|
31
|
+
if (id == null || !/text\/event-stream/i.test(res.headers.get('content-type') || '') || !res.body?.getReader) return res.text()
|
|
32
|
+
const reader = res.body.getReader(), decoder = new TextDecoder()
|
|
33
|
+
let buffered = ''
|
|
34
|
+
try {
|
|
35
|
+
while (true) {
|
|
36
|
+
const { done, value } = await reader.read()
|
|
37
|
+
buffered += decoder.decode(value, { stream: !done })
|
|
38
|
+
let boundary
|
|
39
|
+
while ((boundary = /\r?\n\r?\n/.exec(buffered))) {
|
|
40
|
+
const event = buffered.slice(0, boundary.index)
|
|
41
|
+
buffered = buffered.slice(boundary.index + boundary[0].length)
|
|
42
|
+
if (!event.split(/\r?\n/).some((line) => line.startsWith('data:'))) continue
|
|
43
|
+
const payload = parseBody(event, id)
|
|
44
|
+
if (payload) return JSON.stringify(payload)
|
|
45
|
+
}
|
|
46
|
+
if (done) return buffered
|
|
47
|
+
}
|
|
48
|
+
} finally { void reader.cancel().catch(() => {}) }
|
|
6
49
|
}
|
|
7
50
|
|
|
8
51
|
// Streamable HTTP permits both stateful servers (initialize returns
|
|
@@ -17,6 +60,8 @@ export function createMcpHttpClient({ url, apiKey, identifier, clientVersion, fe
|
|
|
17
60
|
let initializePromise = null
|
|
18
61
|
let toolsPromise = null
|
|
19
62
|
let toolsCache = null
|
|
63
|
+
let generation = 0
|
|
64
|
+
const redact = (value) => apiKey ? String(value).split(apiKey).join('[redacted]') : String(value)
|
|
20
65
|
|
|
21
66
|
const post = async (message, withSession = true) => {
|
|
22
67
|
const controller = new AbortController()
|
|
@@ -43,18 +88,20 @@ export function createMcpHttpClient({ url, apiKey, identifier, clientVersion, fe
|
|
|
43
88
|
},
|
|
44
89
|
body: JSON.stringify(message),
|
|
45
90
|
})
|
|
46
|
-
const body = await res.
|
|
47
|
-
return { ok: res.ok, status: res.status, headers: res.headers, text: async () => body }
|
|
91
|
+
const body = await readBody(res, message.id)
|
|
92
|
+
return { ok: res.ok, status: res.status, headers: res.headers, requestId: message.id, text: async () => body }
|
|
48
93
|
})()])
|
|
49
|
-
}
|
|
94
|
+
} catch (error) { throw new Error(redact(error?.message || error)) }
|
|
95
|
+
finally { clearTimeout(timer) }
|
|
50
96
|
}
|
|
51
97
|
|
|
52
|
-
const reset = () => { initialized = false; sessionId = ''; toolsCache = null }
|
|
98
|
+
const reset = () => { generation++; initialized = false; sessionId = ''; toolsCache = null; initializePromise = null; toolsPromise = null }
|
|
53
99
|
|
|
54
100
|
const initialize = () => {
|
|
55
101
|
if (initialized) return Promise.resolve()
|
|
56
102
|
if (initializePromise) return initializePromise
|
|
57
|
-
|
|
103
|
+
const requestGeneration = generation
|
|
104
|
+
const promise = (async () => {
|
|
58
105
|
const res = await post({
|
|
59
106
|
jsonrpc: '2.0',
|
|
60
107
|
id: ++rpcId,
|
|
@@ -63,15 +110,18 @@ export function createMcpHttpClient({ url, apiKey, identifier, clientVersion, fe
|
|
|
63
110
|
}, false)
|
|
64
111
|
if (!res.ok) throw new Error('MCP initialize HTTP ' + res.status)
|
|
65
112
|
const payload = await parsePayload(res)
|
|
66
|
-
if (payload.error) throw new Error(`MCP initialize: ${payload.error.message || 'protocol error'}`)
|
|
113
|
+
if (payload.error) throw new Error(`MCP initialize: ${redact(payload.error.message || 'protocol error')}`)
|
|
114
|
+
if (requestGeneration !== generation) return initialize()
|
|
67
115
|
sessionId = res.headers.get('mcp-session-id') || ''
|
|
68
116
|
|
|
69
117
|
const ready = await post({ jsonrpc: '2.0', method: 'notifications/initialized' })
|
|
118
|
+
if (requestGeneration !== generation) return initialize()
|
|
70
119
|
if (!ready.ok) { reset(); throw new Error('MCP initialized HTTP ' + ready.status) }
|
|
71
120
|
initialized = true
|
|
72
121
|
if (!sessionId) log('MCP initialized in stateless mode (no session id required)')
|
|
73
|
-
})().finally(() => { initializePromise = null })
|
|
74
|
-
|
|
122
|
+
})().finally(() => { if (initializePromise === promise) initializePromise = null })
|
|
123
|
+
initializePromise = promise
|
|
124
|
+
return promise
|
|
75
125
|
}
|
|
76
126
|
|
|
77
127
|
const callTool = async (name, args = {}, retried = false) => {
|
|
@@ -87,17 +137,17 @@ export function createMcpHttpClient({ url, apiKey, identifier, clientVersion, fe
|
|
|
87
137
|
if (!res.ok) {
|
|
88
138
|
// Only a stateful transport can have an expired session. A stateless 4xx
|
|
89
139
|
// belongs to the tool request itself and must not trigger an initialize loop.
|
|
90
|
-
if (hadSession && !retried &&
|
|
140
|
+
if (hadSession && !retried && res.status === 404) {
|
|
91
141
|
if (sessionId === requestSession) reset()
|
|
92
142
|
return callTool(name, args, true)
|
|
93
143
|
}
|
|
94
144
|
throw new Error(`MCP ${name} HTTP ${res.status}`)
|
|
95
145
|
}
|
|
96
146
|
const payload = await parsePayload(res)
|
|
97
|
-
if (payload.error) throw new Error(`MCP ${name}: ${payload.error.message || 'tool error'}`)
|
|
147
|
+
if (payload.error) throw new Error(`MCP ${name}: ${redact(payload.error.message || 'tool error')}`)
|
|
98
148
|
const result = payload.result ?? payload
|
|
99
149
|
if (result?.isError) {
|
|
100
|
-
const detail =
|
|
150
|
+
const detail = redact(result.content?.find?.((item) => item?.type === 'text')?.text || 'tool error')
|
|
101
151
|
throw new Error(`MCP ${name}: ${detail}`)
|
|
102
152
|
}
|
|
103
153
|
return result
|
|
@@ -105,18 +155,20 @@ export function createMcpHttpClient({ url, apiKey, identifier, clientVersion, fe
|
|
|
105
155
|
|
|
106
156
|
const requestTools = async (retried = false, cursor, collected = [], cursors = new Set()) => {
|
|
107
157
|
await initialize()
|
|
158
|
+
const requestGeneration = generation
|
|
108
159
|
const requestSession = sessionId
|
|
109
160
|
const hadSession = !!sessionId
|
|
110
161
|
const res = await post({ jsonrpc: '2.0', id: ++rpcId, method: 'tools/list', params: cursor ? { cursor } : {} })
|
|
111
162
|
if (!res.ok) {
|
|
112
|
-
if (hadSession && !retried &&
|
|
163
|
+
if (hadSession && !retried && res.status === 404) {
|
|
113
164
|
if (sessionId === requestSession) reset()
|
|
114
165
|
return requestTools(true)
|
|
115
166
|
}
|
|
116
167
|
throw new Error(`MCP tools/list HTTP ${res.status}`)
|
|
117
168
|
}
|
|
118
169
|
const payload = await parsePayload(res)
|
|
119
|
-
if (payload.error) throw new Error(`MCP tools/list: ${payload.error.message || 'protocol error'}`)
|
|
170
|
+
if (payload.error) throw new Error(`MCP tools/list: ${redact(payload.error.message || 'protocol error')}`)
|
|
171
|
+
if (requestGeneration !== generation) return requestTools(retried)
|
|
120
172
|
const tools = payload.result?.tools ?? payload.tools
|
|
121
173
|
if (!Array.isArray(tools)) throw new Error('MCP tools/list returned no tool array')
|
|
122
174
|
const combined = [...collected, ...tools]
|
|
@@ -136,8 +188,9 @@ export function createMcpHttpClient({ url, apiKey, identifier, clientVersion, fe
|
|
|
136
188
|
const listTools = (refresh = false) => {
|
|
137
189
|
if (!refresh && toolsCache) return Promise.resolve(toolsCache)
|
|
138
190
|
if (toolsPromise) return toolsPromise
|
|
139
|
-
|
|
140
|
-
|
|
191
|
+
const promise = requestTools().finally(() => { if (toolsPromise === promise) toolsPromise = null })
|
|
192
|
+
toolsPromise = promise
|
|
193
|
+
return promise
|
|
141
194
|
}
|
|
142
195
|
|
|
143
196
|
const mode = () => !initialized ? 'uninitialized' : sessionId ? 'stateful' : 'stateless'
|
package/src/memory.mjs
CHANGED
|
@@ -12,6 +12,7 @@ const sameRef = (a, b) => a != null && b != null && String(a) === String(b)
|
|
|
12
12
|
// reported. It is intentionally not an LLM transcript or vector store: stable
|
|
13
13
|
// backend ids make exact recall cheaper and prevent old work from being replayed.
|
|
14
14
|
export function createByoMemoryGraph({ path, maxNodes = 1000, now = () => Date.now() }) {
|
|
15
|
+
if (!Number.isSafeInteger(maxNodes) || maxNodes < 1) throw new Error('Invalid BYO memory capacity')
|
|
15
16
|
let raw = {}
|
|
16
17
|
try { raw = JSON.parse(readFileSync(path, 'utf8')) } catch { /* first run */ }
|
|
17
18
|
const nodes = new Map((Array.isArray(raw.nodes) ? raw.nodes : []).filter((node) => node?.key).map((node) => [String(node.key), node]))
|
|
@@ -108,6 +109,10 @@ export function createMastraMemory({ ledgerPath, databasePath, resourceId, maxNo
|
|
|
108
109
|
const memory = new Memory({ storage, options: { lastMessages: 12, semanticRecall: false, workingMemory: { enabled: false } } })
|
|
109
110
|
const threads = new Map()
|
|
110
111
|
const pending = new Map()
|
|
112
|
+
const scopedThreadId = (refs) => {
|
|
113
|
+
const id = memoryThreadId(refs)
|
|
114
|
+
return id ? `resource:${encodeURIComponent(String(resourceId))}:${id}` : ''
|
|
115
|
+
}
|
|
111
116
|
|
|
112
117
|
const ensureThread = (threadId) => {
|
|
113
118
|
if (!threads.has(threadId)) {
|
|
@@ -120,7 +125,7 @@ export function createMastraMemory({ ledgerPath, databasePath, resourceId, maxNo
|
|
|
120
125
|
}
|
|
121
126
|
|
|
122
127
|
const queueMessage = (entry) => {
|
|
123
|
-
const threadId =
|
|
128
|
+
const threadId = scopedThreadId(entry?.refs)
|
|
124
129
|
if (!threadId) return
|
|
125
130
|
const write = (pending.get(threadId) || Promise.resolve()).then(async () => {
|
|
126
131
|
await ensureThread(threadId)
|
|
@@ -142,7 +147,7 @@ export function createMastraMemory({ ledgerPath, databasePath, resourceId, maxNo
|
|
|
142
147
|
}
|
|
143
148
|
|
|
144
149
|
const context = async (refs = {}, limit = 8) => {
|
|
145
|
-
const threadId =
|
|
150
|
+
const threadId = scopedThreadId(refs)
|
|
146
151
|
if (!threadId) return ''
|
|
147
152
|
// Writes for unrelated tickets must never delay a conversation reply.
|
|
148
153
|
await pending.get(threadId)
|
|
@@ -151,6 +156,14 @@ export function createMastraMemory({ ledgerPath, databasePath, resourceId, maxNo
|
|
|
151
156
|
const items = recalled.messages.map(messageText).filter(Boolean).slice(-limit)
|
|
152
157
|
if (items.length) return ['RELEVANT VERIFIED MEMORY (do not repeat completed/delivered actions):', ...items.map((item) => `- ${item}`)].join('\n')
|
|
153
158
|
} catch { /* fall through to the migration-safe ledger */ }
|
|
159
|
+
// Preserve pre-namespace history only when Mastra verifies that the legacy
|
|
160
|
+
// thread belongs to this resource. New writes use isolated IDs even if two
|
|
161
|
+
// agents share the same database and ticket numbers.
|
|
162
|
+
try {
|
|
163
|
+
const legacy = await memory.recall({ threadId: memoryThreadId(refs), resourceId, perPage: limit })
|
|
164
|
+
const items = legacy.messages.map(messageText).filter(Boolean).slice(-limit)
|
|
165
|
+
if (items.length) return ['RELEVANT VERIFIED MEMORY (do not repeat completed/delivered actions):', ...items.map((item) => `- ${item}`)].join('\n')
|
|
166
|
+
} catch { /* no legacy thread owned by this resource */ }
|
|
154
167
|
return ledger.context(refs, limit)
|
|
155
168
|
}
|
|
156
169
|
|
package/src/pr-push.mjs
CHANGED
|
@@ -15,9 +15,9 @@ const gitText = (cwd, args, spawn = spawnSync) => {
|
|
|
15
15
|
|
|
16
16
|
const safeRemote = (value) => {
|
|
17
17
|
const remote = String(value || '').trim()
|
|
18
|
-
if (/^https
|
|
18
|
+
if (/^https:\/\//i.test(remote) || /^ssh:\/\//i.test(remote)) {
|
|
19
19
|
const parsed = new URL(remote)
|
|
20
|
-
if (parsed.
|
|
20
|
+
if (parsed.password || (parsed.protocol !== 'ssh:' && parsed.username)) throw new Error('Origin contains credentials in its URL. Move credentials to your Git credential manager before authorizing PR pushes.')
|
|
21
21
|
return remote
|
|
22
22
|
}
|
|
23
23
|
if (/^[^@\s]+@[^:\s]+:[^\s]+$/.test(remote)) return remote
|
|
@@ -27,6 +27,7 @@ const safeRemote = (value) => {
|
|
|
27
27
|
export function isSafeAgentBranch(value) {
|
|
28
28
|
const branch = String(value || '')
|
|
29
29
|
return /^agent\/[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(branch) &&
|
|
30
|
+
branch.split('/').every((part) => !part.startsWith('.') && !part.endsWith('.lock')) &&
|
|
30
31
|
!branch.includes('..') && !branch.includes('//') && !branch.includes('@{') &&
|
|
31
32
|
!/[~^:?*\[\\]/.test(branch) && !/[/.]$/.test(branch)
|
|
32
33
|
}
|
|
@@ -52,7 +53,15 @@ function readAuthorizations(path) {
|
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
function readPolicy(path) {
|
|
55
|
-
try {
|
|
56
|
+
try {
|
|
57
|
+
const value = JSON.parse(readFileSync(path, 'utf8'))
|
|
58
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
|
|
59
|
+
return { ...value,
|
|
60
|
+
authorizations: Array.isArray(value.authorizations) ? value.authorizations.filter((entry) => entry && typeof entry.root === 'string' && typeof entry.remote === 'string') : [],
|
|
61
|
+
workspaces: Array.isArray(value.workspaces) ? value.workspaces.filter((entry) => entry && typeof entry.root === 'string' && isAbsolute(entry.root)) : [],
|
|
62
|
+
revoked: Array.isArray(value.revoked) ? value.revoked.filter((entry) => typeof entry === 'string') : [],
|
|
63
|
+
}
|
|
64
|
+
} catch { return {} }
|
|
56
65
|
}
|
|
57
66
|
|
|
58
67
|
const within = (root, path) => {
|
|
@@ -63,10 +72,10 @@ const within = (root, path) => {
|
|
|
63
72
|
|
|
64
73
|
function policyAllows(identity, policy) {
|
|
65
74
|
const roots = [identity.root, identity.commonDir].filter(Boolean)
|
|
66
|
-
if ((policy.revoked
|
|
67
|
-
return (policy.authorizations
|
|
75
|
+
if ((Array.isArray(policy.revoked) ? policy.revoked : []).some((entry) => roots.includes(entry))) return false
|
|
76
|
+
return (Array.isArray(policy.authorizations) ? policy.authorizations : []).some((entry) => entry && entry.remote === identity.remote &&
|
|
68
77
|
(entry.root === identity.root || (entry.commonDir && entry.commonDir === identity.commonDir))) ||
|
|
69
|
-
(policy.workspaces
|
|
78
|
+
(Array.isArray(policy.workspaces) ? policy.workspaces : []).some((entry) => typeof entry?.root === 'string' && isAbsolute(entry.root) && roots.some((root) => within(entry.root, root)))
|
|
70
79
|
}
|
|
71
80
|
|
|
72
81
|
// Connecting a coding workspace grants routine PR delivery once, including
|
|
@@ -128,7 +137,10 @@ export function authorizePrPush({ cwd = process.cwd(), authPath = DEFAULT_AUTH_P
|
|
|
128
137
|
}
|
|
129
138
|
|
|
130
139
|
export function revokePrPush({ cwd = process.cwd(), authPath = DEFAULT_AUTH_PATH, spawn = spawnSync } = {}) {
|
|
131
|
-
|
|
140
|
+
// Revocation must still work if origin was removed or replaced with an unsafe
|
|
141
|
+
// URL; only a future authorization or push needs to validate the destination.
|
|
142
|
+
const root = realpathSync(gitText(cwd, ['rev-parse', '--show-toplevel'], spawn))
|
|
143
|
+
const commonDir = realpathSync(resolve(root, gitText(root, ['rev-parse', '--git-common-dir'], spawn)))
|
|
132
144
|
const policy = readPolicy(authPath)
|
|
133
145
|
const before = readAuthorizations(authPath)
|
|
134
146
|
const authorizations = before.filter((entry) => entry?.root !== root)
|
|
@@ -138,7 +150,8 @@ export function revokePrPush({ cwd = process.cwd(), authPath = DEFAULT_AUTH_PATH
|
|
|
138
150
|
|
|
139
151
|
export function pushPrBranch({ cwd = process.cwd(), authPath = DEFAULT_AUTH_PATH, spawn = spawnSync } = {}) {
|
|
140
152
|
const identity = repositoryPushIdentity(cwd, spawn)
|
|
141
|
-
|
|
153
|
+
const { authorizations, workspaces, revoked } = readPolicy(authPath)
|
|
154
|
+
validatePrPushAuthorization({ ...identity, authorizations, workspaces, revoked })
|
|
142
155
|
const destination = `HEAD:refs/heads/${identity.branch}`
|
|
143
156
|
// The helper runs outside Codex's workspace sandbox after the user authorizes
|
|
144
157
|
// it. Disable repository hooks so an edited pre-push hook cannot widen this
|
package/src/runner-pool.mjs
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
// Bounded LRU of warm sessions, pinned while in use. Never evict an active
|
|
2
2
|
// session, and never share history between tickets or conversation threads.
|
|
3
|
-
export function createRunnerPool({ create, capacity = 8, idleMs = 300_000, now = Date.now }) {
|
|
3
|
+
export function createRunnerPool({ create, capacity = 8, idleMs = 300_000, now = Date.now, onError = () => {} }) {
|
|
4
|
+
if (!Number.isInteger(capacity) || capacity < 1) throw new Error('runner pool capacity must be a positive integer')
|
|
5
|
+
if (!Number.isFinite(idleMs) || idleMs <= 0) throw new Error('runner pool idleMs must be positive and finite')
|
|
4
6
|
const entries = new Map()
|
|
7
|
+
let closed = false
|
|
8
|
+
const report = (error) => { try { Promise.resolve(onError(error)).catch(() => {}) } catch { /* reporting cannot strand cleanup */ } }
|
|
9
|
+
const dispose = (entry) => {
|
|
10
|
+
try { Promise.resolve(entry.runner.close?.()).catch(report) } catch (error) { report(error) }
|
|
11
|
+
}
|
|
5
12
|
function prune() {
|
|
6
13
|
for (const [key, entry] of entries) {
|
|
7
14
|
if (!entry.busy && (now() - entry.usedAt >= idleMs || entries.size > capacity)) {
|
|
8
|
-
entry.runner.close?.()
|
|
9
15
|
entries.delete(key)
|
|
16
|
+
dispose(entry)
|
|
10
17
|
}
|
|
11
18
|
}
|
|
12
19
|
}
|
|
@@ -14,6 +21,7 @@ export function createRunnerPool({ create, capacity = 8, idleMs = 300_000, now =
|
|
|
14
21
|
timer.unref?.()
|
|
15
22
|
return {
|
|
16
23
|
acquire(key, options) {
|
|
24
|
+
if (closed) throw new Error('Session pool is closed')
|
|
17
25
|
prune()
|
|
18
26
|
let entry = entries.get(key)
|
|
19
27
|
if (entry?.busy) throw new Error('Session already has an active cycle')
|
|
@@ -30,9 +38,12 @@ export function createRunnerPool({ create, capacity = 8, idleMs = 300_000, now =
|
|
|
30
38
|
prune()
|
|
31
39
|
},
|
|
32
40
|
close() {
|
|
41
|
+
if (closed) return
|
|
42
|
+
closed = true
|
|
33
43
|
clearInterval(timer)
|
|
34
|
-
|
|
44
|
+
const closing = [...entries.values()]
|
|
35
45
|
entries.clear()
|
|
46
|
+
for (const entry of closing) dispose(entry)
|
|
36
47
|
},
|
|
37
48
|
}
|
|
38
49
|
}
|