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.
@@ -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 selected = options.find((option) => option.kind === preferred) || options.find((option) => option.kind.startsWith(allow ? 'allow' : 'reject'))
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?.connection.disconnect()
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
- // codex-acp publishes account-status extension notifications. They are
159
- // useful to interactive clients but should be silent in a background
160
- // watcher, and the stock Mastra ACP client intentionally knows only ACP.
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
- acp.connection.disconnect()
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
- for await (const event of acp.connection.promptStream(fullPrompt, controller.signal)) {
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
- const merged = { ...prior, ...update, order: ++toolSequence }
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 ||= name === 'post_message'
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 = Object.values(mcpHeaders).filter(Boolean).reduce((safe, secret) => safe.split(String(secret)).join('[redacted]'), errorDetail)
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
- try { await acp.connection.cancel() } catch { /* already stopped */ }
285
- acp.connection.disconnect()
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
- persistent?.connection.disconnect()
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 body = await res.text()
3
- const data = body.split(/\r?\n/).filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trim()).pop()
4
- try { return JSON.parse(data || body || '{}') }
5
- catch { throw new Error('MCP returned a non-JSON response') }
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.text()
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
- } finally { clearTimeout(timer) }
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
- initializePromise = (async () => {
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
- return initializePromise
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 && [400, 404, 409, 410].includes(res.status)) {
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 = String(result.content?.find?.((item) => item?.type === 'text')?.text || 'tool error').split(apiKey).join('[redacted]')
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 && [400, 404, 409, 410].includes(res.status)) {
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
- toolsPromise = requestTools().finally(() => { toolsPromise = null })
140
- return toolsPromise
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 = memoryThreadId(entry?.refs)
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 = memoryThreadId(refs)
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?:\/\//i.test(remote) || /^ssh:\/\//i.test(remote)) {
18
+ if (/^https:\/\//i.test(remote) || /^ssh:\/\//i.test(remote)) {
19
19
  const parsed = new URL(remote)
20
- 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
+ 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 { return JSON.parse(readFileSync(path, 'utf8')) } catch { return {} }
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 || []).some((entry) => roots.includes(entry))) return false
67
- return (policy.authorizations || []).some((entry) => entry.remote === identity.remote &&
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 || []).some((entry) => roots.some((root) => within(entry.root, root)))
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
- const { root, commonDir } = repositoryPushIdentity(cwd, spawn)
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
- validatePrPushAuthorization({ ...identity, ...readPolicy(authPath) })
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
@@ -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
- for (const entry of entries.values()) entry.runner.close?.()
44
+ const closing = [...entries.values()]
35
45
  entries.clear()
46
+ for (const entry of closing) dispose(entry)
36
47
  },
37
48
  }
38
49
  }