openvisio-agent 0.19.0 → 0.19.2
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 +3 -1
- package/package.json +1 -1
- package/scripts/certify.mjs +4 -1
- package/src/events.mjs +8 -5
- package/src/mastra-harness.mjs +19 -5
- package/src/model-selection.mjs +75 -0
- package/src/watch.mjs +22 -10
package/README.md
CHANGED
|
@@ -58,13 +58,15 @@ Runs the **autonomy loop** — the agent replies to @mentions and picks up ticke
|
|
|
58
58
|
|
|
59
59
|
Codex and OpenCode cycles run through Mastra's ACP harness. Codex uses the packaged `codex-acp` adapter and reuses the machine's existing ChatGPT/Codex login; OpenCode uses its native `opencode acp` server. Each accepted ticket gets its own ACP session and worktree. Mastra Memory stores ticket/thread context in local libSQL under `~/.openvisio/`, while a compact JSON ledger retains only exact replay, cancellation, and delivery keys.
|
|
60
60
|
|
|
61
|
+
Model names are negotiated at session start. Codex and OpenCode match the configured name against the models advertised by ACP, preferring the same family and reasoning effort (`medium` for an unsuffixed family); provider-qualified and nearby generation names are resolved automatically. Claude versioned names are normalized to its stable `sonnet`, `opus`, `haiku`, or `fable` aliases and use the CLI's native ordered fallback chain.
|
|
62
|
+
|
|
61
63
|
Backend/BYO watchers reconcile immediately whenever the process starts or the WebSocket connects. A direct MCP session discovers and caches the backend's actual `tools/list` response, then uses available tools such as `list_agents`, `list_projects`, `list_tasks`, `list_task_types`, and `list_activity` to recover assigned tasks and recent mention activity missed while offline. Optional actions such as ticket comments are used only when advertised; their absence cannot strand a completed ticket in a retry loop. The same zero-model check runs every five minutes as a safety net; a model starts only when pending work exists.
|
|
62
64
|
|
|
63
65
|
The backend MCP may be stateful or stateless. A successful initialize response without `Mcp-Session-Id` is accepted as stateless, so OpenCode agents do not stop with “MCP initialize returned no session id.” Each OpenCode lane keeps its MCP identity in a private per-agent config directory while the repository is supplied separately with `--dir`; stale workspace configuration therefore cannot swap one agent's credentials for another's. The generated remote configuration sends the agent headers directly, disables OAuth probing, and backend cycles never request relay-only inbox calls or MCP resource-discovery tools in place of team actions.
|
|
64
66
|
|
|
65
67
|
Codex BYO agents follow the repository's normative runtime specification in `docs/CODEX_BYO_AGENT_SPEC.md`: one WebSocket identity, independent Sol reply/work lanes, authoritative `get_ticket` verification for assignments, REST-backed in-app activity, persistent replay suppression, and runtime evidence gates before completion. Maintainers must run `npm run certify` before publishing.
|
|
66
68
|
|
|
67
|
-
An `agent:mention` event only wakes the watcher;
|
|
69
|
+
An `agent:mention` event only wakes the watcher; the actual source message is checked before any model starts. An explicit tag establishes durable ownership of that thread, so later human follow-ups remain addressed to the agent without another @mention. Messages redirected to another agent and unaddressed agent chatter stay silent, while a direct stand-down cancels queued/running work and releases ownership for that thread. The watcher does not broadcast working, thinking, or typing presence updates. Claude and OpenCode may add one concrete progress update after work begins, but must continue and post a distinct verified result or blocker afterward; Codex keeps cancellation-safe delivery watcher-owned and renders the verified final answer once.
|
|
68
70
|
|
|
69
71
|
Ticket references follow the board UI: BYO agents use the project-scoped slug, such as `OVS-57`, in messages, comments, PR descriptions, blockers, and results. Numeric `project_id` and `ticket_id` values remain internal MCP arguments and are never used as human-facing ticket names. If an older backend omits the slug, the agent uses the ticket title rather than inventing one.
|
|
70
72
|
|
package/package.json
CHANGED
package/scripts/certify.mjs
CHANGED
|
@@ -32,6 +32,7 @@ const cli = readFileSync(join(root, 'bin', 'cli.mjs'), 'utf8')
|
|
|
32
32
|
const websocket = readFileSync(join(root, 'src', 'ws.mjs'), 'utf8')
|
|
33
33
|
const cycleQueue = readFileSync(join(root, 'src', 'cycle-queue.mjs'), 'utf8')
|
|
34
34
|
const mastraHarness = readFileSync(join(root, 'src', 'mastra-harness.mjs'), 'utf8')
|
|
35
|
+
const modelSelection = readFileSync(join(root, 'src', 'model-selection.mjs'), 'utf8')
|
|
35
36
|
const activityHook = readFileSync(join(repo, 'frontend', 'hooks', 'useAgentActivity.ts'), 'utf8')
|
|
36
37
|
const taskHook = readFileSync(join(repo, 'frontend', 'hooks', 'useBackendTasks.ts'), 'utf8')
|
|
37
38
|
const liveTasks = readFileSync(join(repo, 'frontend', 'lib', 'collab', 'liveTasks.ts'), 'utf8')
|
|
@@ -55,7 +56,8 @@ const assertions = [
|
|
|
55
56
|
['single-watcher acquisition is atomic and fails closed', watcher.includes("openSync(lockPath, 'wx')") && watcher.includes('Could not acquire the single-watcher lock')],
|
|
56
57
|
['websocket and activity mention delivery share a replay guard', watcher.includes('markMentionHandled(activityMessage, activityChannelId)') && watcher.includes('markMentionHandled(msg, cid)') && watcher.includes('recentMentionSignatures')],
|
|
57
58
|
['reconciled mentions reuse the guarded websocket delivery path', watcher.includes("onEvent('agent:mention'") && watcher.includes('_mentionAlreadyMarked: true')],
|
|
58
|
-
['conversation wake events
|
|
59
|
+
['conversation wake events preserve owned-thread follow-ups and filter other recipients before model start', watcher.includes("memory.has(controlKey, 'active')") && watcher.includes('classifyConversationTarget(msg, [...selfAliases], { threadOwned })') && events.includes("reason: 'owned-thread-follow-up'") && events.includes("reason: 'explicit-other-recipient'") && events.includes("reason: 'other-agent-chatter'")],
|
|
60
|
+
['prematurely acknowledged owned-thread replies recover exactly once', watcher.includes("!threadOwned || memory.has(sourceKey, 'received')") && watcher.includes('agent:mention replay recovered for active owned thread')],
|
|
59
61
|
['coding mentions require an action and concrete repository target', watcher.includes('conversationNeedsCode(text)') && events.includes('const action =') && events.includes('const target =')],
|
|
60
62
|
['stand-down and redirects cancel only source-thread workers', watcher.includes('cancelThread(cid, threadRoot') && watcher.includes('item.control.cancelled = true') && watcher.includes('item.control.runner?.cancelCurrent') && watcher.includes("subtype === 'canceled'")],
|
|
61
63
|
['thread cancellation is rechecked immediately before watcher delivery', watcher.includes('const newlyCancelled = suppressCancelled()') && watcher.includes('if (newlyCancelled) return newlyCancelled')],
|
|
@@ -74,6 +76,7 @@ const assertions = [
|
|
|
74
76
|
['MCP requests have a deadline including response bodies', mcpHttp.includes('controller.abort()') && mcpHttp.includes('const body = await res.text()')],
|
|
75
77
|
['Mastra memory uses real ticket and thread identities', watcher.includes('createMastraMemory') && watcher.includes('await memory.context(memoryRefs)') && memory.includes('new Memory({ storage') && memory.includes('new LibSQLStore') && memory.includes('ticket:${refs.projectId}:${refs.ticketId}') && memory.includes('channel:${refs.channelId}:thread:')],
|
|
76
78
|
['Codex and OpenCode use isolated Mastra ACP sessions', watcher.includes('createMastraAcpRunner') && mastraHarness.includes('new AcpAgentClass') && mastraHarness.includes('persistSession: false') && mastraHarness.includes("runtime: 'mastra-acp'")],
|
|
79
|
+
['runtime models are resolved before work starts', mastraHarness.includes('getAvailableModels()') && mastraHarness.includes('resolveAvailableModel(requestedModel') && mastraHarness.includes('await acp.setModel(selectedModel)') && modelSelection.includes("const DEFAULT_EFFORT = 'medium'") && watcher.includes('resolveClaudeModel') && watcher.includes("'--fallback-model'" )],
|
|
77
80
|
['one watcher owns isolated concurrent work and serial reply runtimes', watcher.includes('MAX_CONCURRENT_WORKERS = 3') && watcher.includes('createWorkRunner(item.control, item.workdir)') && watcher.includes('const replyRunner = createCycleRunner')],
|
|
78
81
|
['workers serialize shared worktrees while independent worktrees run concurrently', watcher.includes('groupKey: (item) => item.workdir || workdir') && cycleQueue.includes('active.size < concurrency') && cycleQueue.includes('activeGroups.has(entry.group)')],
|
|
79
82
|
['work and reply cancellation targets are isolated', watcher.includes('item.control.cancelled = true') && watcher.includes('item.control.runner?.cancelCurrent')],
|
package/src/events.mjs
CHANGED
|
@@ -450,7 +450,7 @@ export function messageSenderIsAgent(message) {
|
|
|
450
450
|
// `agent:mention`, even when the new message names somebody else. Treat the event
|
|
451
451
|
// name as a wake-up hint only: recipient ownership is decided from the actual
|
|
452
452
|
// message before a model or work lane is allowed to run.
|
|
453
|
-
export function classifyConversationTarget(message, selfAliases) {
|
|
453
|
+
export function classifyConversationTarget(message, selfAliases, { threadOwned = false } = {}) {
|
|
454
454
|
const m = message && typeof message === 'object' ? message : {}
|
|
455
455
|
const text = String(m.content ?? m.body ?? m.text ?? m.message ?? '').replace(/\s+/g, ' ').trim()
|
|
456
456
|
const { self: selfMentions, other: otherMentions, all: mentions } = conversationMentions(text, selfAliases)
|
|
@@ -471,11 +471,14 @@ export function classifyConversationTarget(message, selfAliases) {
|
|
|
471
471
|
if (explicitSelf && requestTargetsLaterAgent(text, selfAliases)) {
|
|
472
472
|
return { action: 'ignore', reason: 'redirected-to-later-agent', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
|
|
473
473
|
}
|
|
474
|
-
//
|
|
475
|
-
//
|
|
476
|
-
//
|
|
477
|
-
//
|
|
474
|
+
// An explicit mention establishes durable thread ownership in the watcher.
|
|
475
|
+
// Later human follow-ups in that same active thread remain addressed to this
|
|
476
|
+
// agent without requiring another @mention. Redirects, stand-downs and agent
|
|
477
|
+
// chatter were handled above and still fail closed.
|
|
478
478
|
if (!explicitSelf) {
|
|
479
|
+
if (threadOwned) {
|
|
480
|
+
return { action: 'handle', reason: 'owned-thread-follow-up', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
|
|
481
|
+
}
|
|
479
482
|
return { action: 'ignore', reason: 'unaddressed-thread-activity', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
|
|
480
483
|
}
|
|
481
484
|
return { action: 'handle', reason: 'explicit-self-recipient', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
|
package/src/mastra-harness.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process'
|
|
|
2
2
|
import { fileURLToPath } from 'node:url'
|
|
3
3
|
import { AcpAgent } from '@mastra/acp'
|
|
4
4
|
import { onPath } from './lib.mjs'
|
|
5
|
+
import { resolveAvailableModel } from './model-selection.mjs'
|
|
5
6
|
|
|
6
7
|
const MCP_TOOL_NAMES = [
|
|
7
8
|
'list_agents', 'list_projects', 'list_tasks', 'list_task_types', 'get_ticket',
|
|
@@ -97,7 +98,6 @@ export function createMastraAcpRunner({
|
|
|
97
98
|
mcpServers,
|
|
98
99
|
},
|
|
99
100
|
persistSession: false,
|
|
100
|
-
...(cycleModel || model ? { model: cycleModel || model } : {}),
|
|
101
101
|
onPermissionRequest: async ({ options }) => {
|
|
102
102
|
const kind = canCode ? 'allow_once' : 'reject_once'
|
|
103
103
|
const selected = options.find((option) => option.kind === kind) || options.find((option) => option.kind.startsWith(canCode ? 'allow' : 'reject'))
|
|
@@ -114,9 +114,22 @@ export function createMastraAcpRunner({
|
|
|
114
114
|
}) : undefined,
|
|
115
115
|
})
|
|
116
116
|
active = { acp, controller }
|
|
117
|
-
const timer = setTimeout(() => { timedOut = true; controller.abort() }, maxCycleMs)
|
|
118
|
-
|
|
117
|
+
const timer = setTimeout(() => { timedOut = true; controller.abort(); acp.connection.disconnect() }, maxCycleMs)
|
|
118
|
+
const requestedModel = cycleModel || model || ''
|
|
119
|
+
let selectedModel = ''
|
|
119
120
|
try {
|
|
121
|
+
if (requestedModel) {
|
|
122
|
+
const availableModels = await acp.getAvailableModels()
|
|
123
|
+
const resolution = resolveAvailableModel(requestedModel, availableModels)
|
|
124
|
+
selectedModel = resolution.selected
|
|
125
|
+
if (selectedModel) {
|
|
126
|
+
await acp.setModel(selectedModel)
|
|
127
|
+
if (selectedModel !== requestedModel) log(`model ${requestedModel} resolved to available ${selectedModel} [${resolution.reason}]`)
|
|
128
|
+
} else {
|
|
129
|
+
log(`model ${requestedModel} could not be negotiated because ${agent} did not advertise selectable models; using its session default`)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
log(`running ${agent} cycle via Mastra ACP…${selectedModel || requestedModel ? ` [${selectedModel || requestedModel}]` : ' [runtime default]'}`)
|
|
120
133
|
const fullPrompt = systemPrompt ? `${systemPrompt}\n\n${prompt}` : prompt
|
|
121
134
|
for await (const event of acp.connection.promptStream(fullPrompt, controller.signal)) {
|
|
122
135
|
if (event.type === 'text') { outputText += event.text; continue }
|
|
@@ -152,7 +165,7 @@ export function createMastraAcpRunner({
|
|
|
152
165
|
didResultMessage = didChannelMessage && didRepoMutation
|
|
153
166
|
log(`${agent} cycle done via Mastra ACP (ok)`)
|
|
154
167
|
return {
|
|
155
|
-
type: 'result', subtype: 'ok', runtime: 'mastra-acp', outputText: outputText.trim(),
|
|
168
|
+
type: 'result', subtype: 'ok', runtime: 'mastra-acp', model: selectedModel || null, requestedModel: requestedModel || null, outputText: outputText.trim(),
|
|
156
169
|
mcpCalls: [...calls], mcpErrors: [...errors.keys()], mcpErrorDetails: Object.fromEntries(errors),
|
|
157
170
|
didCode, didRepoMutation, didMessage, didChannelMessage, didResultMessage,
|
|
158
171
|
didMcpTaskRead, didMcpTaskUpdate,
|
|
@@ -162,7 +175,7 @@ export function createMastraAcpRunner({
|
|
|
162
175
|
const subtype = timedOut ? 'timeout' : canceled ? 'canceled' : 'error'
|
|
163
176
|
log(`${agent} cycle done via Mastra ACP (${subtype}${!canceled && error?.message ? ': ' + clean(error.message) : ''})`)
|
|
164
177
|
return {
|
|
165
|
-
type: 'result', subtype, runtime: 'mastra-acp', outputText: outputText.trim(),
|
|
178
|
+
type: 'result', subtype, runtime: 'mastra-acp', model: selectedModel || null, requestedModel: requestedModel || null, outputText: outputText.trim(),
|
|
166
179
|
mcpCalls: [...calls], mcpErrors: [...errors.keys()], mcpErrorDetails: Object.fromEntries(errors),
|
|
167
180
|
didCode, didRepoMutation, didMessage, didChannelMessage, didResultMessage,
|
|
168
181
|
didMcpTaskRead, didMcpTaskUpdate,
|
|
@@ -178,6 +191,7 @@ export function createMastraAcpRunner({
|
|
|
178
191
|
if (!active) return
|
|
179
192
|
active.controller.abort()
|
|
180
193
|
try { await active.acp.connection.cancel() } catch { /* already stopped */ }
|
|
194
|
+
active.acp.connection.disconnect()
|
|
181
195
|
}
|
|
182
196
|
|
|
183
197
|
log(`${agent} Mastra ACP runner ready${canCode ? ` [CODE workspace ${defaultCwd}]` : ' [CHAT-ONLY]'}`)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max', 'ultra']
|
|
2
|
+
const DEFAULT_EFFORT = 'medium'
|
|
3
|
+
|
|
4
|
+
const text = (value) => String(value ?? '').trim()
|
|
5
|
+
const lower = (value) => text(value).toLowerCase()
|
|
6
|
+
|
|
7
|
+
function parts(value) {
|
|
8
|
+
const raw = text(value)
|
|
9
|
+
const effortMatch = /\[([^\]]+)\]$/.exec(raw)
|
|
10
|
+
const withoutEffort = effortMatch ? raw.slice(0, effortMatch.index) : raw
|
|
11
|
+
const path = lower(withoutEffort).split('/').filter(Boolean)
|
|
12
|
+
const leaf = path.at(-1) || ''
|
|
13
|
+
const tokens = leaf.split(/[^a-z0-9]+/).filter(Boolean)
|
|
14
|
+
return { raw, id: lower(raw), base: lower(withoutEffort), leaf, tokens, provider: path.slice(0, -1).join('/'), effort: lower(effortMatch?.[1]) }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const effortDistance = (actual, preferred) => {
|
|
18
|
+
const actualIndex = EFFORTS.indexOf(actual)
|
|
19
|
+
const preferredIndex = EFFORTS.indexOf(preferred)
|
|
20
|
+
if (actualIndex < 0) return 20
|
|
21
|
+
return Math.abs(actualIndex - (preferredIndex < 0 ? EFFORTS.indexOf(DEFAULT_EFFORT) : preferredIndex))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function similarity(requested, candidate, preferredEffort) {
|
|
25
|
+
if (candidate.id === requested.id) return 100_000
|
|
26
|
+
let score = 0
|
|
27
|
+
if (candidate.base === requested.base || candidate.leaf === requested.leaf) score += 50_000
|
|
28
|
+
if (requested.provider && candidate.provider === requested.provider) score += 2_000
|
|
29
|
+
const requestedTokens = new Set(requested.tokens)
|
|
30
|
+
const shared = candidate.tokens.filter((token) => requestedTokens.has(token)).length
|
|
31
|
+
score += shared * 1_000
|
|
32
|
+
if (requested.leaf && (candidate.leaf.includes(requested.leaf) || requested.leaf.includes(candidate.leaf))) score += 10_000
|
|
33
|
+
let prefix = 0
|
|
34
|
+
while (prefix < requested.leaf.length && prefix < candidate.leaf.length && requested.leaf[prefix] === candidate.leaf[prefix]) prefix++
|
|
35
|
+
score += prefix * 10
|
|
36
|
+
score -= effortDistance(candidate.effort, requested.effort || preferredEffort) * 100
|
|
37
|
+
return score
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function resolveAvailableModel(requestedValue, availableModels = [], { preferredEffort = DEFAULT_EFFORT } = {}) {
|
|
41
|
+
const requested = parts(requestedValue)
|
|
42
|
+
const choices = availableModels.map((entry) => typeof entry === 'string' ? entry : entry?.modelId).map(text).filter(Boolean)
|
|
43
|
+
if (!choices.length) return { requested: requested.raw, selected: '', reason: 'runtime-default', available: [] }
|
|
44
|
+
if (!requested.raw) return { requested: '', selected: '', reason: 'runtime-default', available: choices }
|
|
45
|
+
|
|
46
|
+
const ranked = choices.map((id, index) => {
|
|
47
|
+
const candidate = parts(id)
|
|
48
|
+
return { id, index, score: similarity(requested, candidate, preferredEffort) }
|
|
49
|
+
}).sort((a, b) => b.score - a.score || a.index - b.index)
|
|
50
|
+
const winner = ranked[0]
|
|
51
|
+
return {
|
|
52
|
+
requested: requested.raw,
|
|
53
|
+
selected: winner.id,
|
|
54
|
+
reason: lower(winner.id) === requested.id ? 'exact' : parts(winner.id).base === requested.base || parts(winner.id).leaf === requested.leaf ? 'family-effort' : 'nearest',
|
|
55
|
+
available: choices,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const CLAUDE_FAMILIES = ['sonnet', 'opus', 'haiku', 'fable']
|
|
60
|
+
|
|
61
|
+
export function resolveClaudeModel(requestedValue) {
|
|
62
|
+
const requested = lower(requestedValue)
|
|
63
|
+
if (!requested) return ''
|
|
64
|
+
const family = CLAUDE_FAMILIES.find((name) => requested === name || requested.startsWith('claude-') && requested.includes(name))
|
|
65
|
+
return family || text(requestedValue)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function claudeFallbackModels(selectedValue) {
|
|
69
|
+
const selected = lower(selectedValue)
|
|
70
|
+
const preferred = selected === 'opus' ? ['sonnet', 'haiku']
|
|
71
|
+
: selected === 'haiku' ? ['sonnet', 'opus']
|
|
72
|
+
: selected === 'fable' ? ['sonnet', 'opus'] : ['opus', 'haiku']
|
|
73
|
+
return preferred.filter((model) => model !== selected)
|
|
74
|
+
}
|
|
75
|
+
|
package/src/watch.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import { buildCodexMcpOverride } from './codex-config.mjs'
|
|
|
20
20
|
import { createCycleQueue } from './cycle-queue.mjs'
|
|
21
21
|
import { modelProcessOptions, stopModelProcess } from './process-lifecycle.mjs'
|
|
22
22
|
import { createMastraAcpRunner } from './mastra-harness.mjs'
|
|
23
|
+
import { claudeFallbackModels, resolveClaudeModel } from './model-selection.mjs'
|
|
23
24
|
|
|
24
25
|
function findTicketWorktree(workspaceRoot, ticketId) {
|
|
25
26
|
if (!workspaceRoot || ticketId == null) return ''
|
|
@@ -613,7 +614,7 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
613
614
|
// The model the CURRENT session was spawned with. runCycle can pass a different
|
|
614
615
|
// model per cycle (cheap for chat, stronger for code) — a change recycles the
|
|
615
616
|
// session so the new model takes effect.
|
|
616
|
-
let sessionModel = model || null
|
|
617
|
+
let sessionModel = resolveClaudeModel(model) || null
|
|
617
618
|
let turnsThisSession = 0
|
|
618
619
|
let sessionStartedAt = 0
|
|
619
620
|
let resolveTurn = null
|
|
@@ -670,7 +671,8 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
670
671
|
if (!mcpConfig) { log('WARNING: no MCP config — the agent can react to events but has no tools to act. Re-connect with --mcp-url.') }
|
|
671
672
|
// The charter + creds ride in the system prompt (cacheable → not re-billed each
|
|
672
673
|
// cycle), leaving only the small per-cycle instruction in the user message.
|
|
673
|
-
const
|
|
674
|
+
const fallbackModels = claudeFallbackModels(sessionModel)
|
|
675
|
+
const base = ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--strict-mcp-config', '--mcp-config', mcpConfig, ...(sessionModel ? ['--model', sessionModel, ...(fallbackModels.length ? ['--fallback-model', fallbackModels.join(',')] : [])] : []), ...(systemPrompt ? ['--append-system-prompt', systemPrompt] : [])]
|
|
674
676
|
const args = canCode ? [...base, '--allowedTools', ...CODE_TOOLS, '--disallowedTools', ...DENY_TOOLS] : [...base, '--allowedTools', 'mcp__openvisio-team__*']
|
|
675
677
|
const c = spawn(claude, args, { ...modelProcessOptions, cwd: workdir || undefined, stdio: ['pipe', 'pipe', 'inherit'] })
|
|
676
678
|
child = c
|
|
@@ -731,9 +733,11 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
731
733
|
return new Promise((resolve) => {
|
|
732
734
|
// A per-cycle model override (e.g. chat on a cheaper model than code) — a
|
|
733
735
|
// change means the warm session must be respawned with the new --model.
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
sessionModel =
|
|
736
|
+
const resolvedCycleModel = resolveClaudeModel(cycleModel)
|
|
737
|
+
if (resolvedCycleModel && resolvedCycleModel !== sessionModel) {
|
|
738
|
+
if (child) { log('model change ' + (sessionModel || 'default') + ' → ' + resolvedCycleModel + ' — recycling session'); try { child.kill() } catch { /* gone */ } child = null }
|
|
739
|
+
if (resolvedCycleModel !== cycleModel) log('model ' + cycleModel + ' resolved to Claude family alias ' + resolvedCycleModel)
|
|
740
|
+
sessionModel = resolvedCycleModel
|
|
737
741
|
}
|
|
738
742
|
if (child && (turnsThisSession >= MAX_TURNS || Date.now() - sessionStartedAt > SESSION_IDLE_MS)) {
|
|
739
743
|
log('recycling session (turns=' + turnsThisSession + ')')
|
|
@@ -1705,11 +1709,21 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1705
1709
|
const mid = msg.id != null ? msg.id : (msg.message_id != null ? msg.message_id : null)
|
|
1706
1710
|
const parent = msg.parent_id != null ? msg.parent_id : (msg.parentId != null ? msg.parentId : null)
|
|
1707
1711
|
const threadRoot = parent != null ? parent : mid
|
|
1712
|
+
const controlKey = threadControlKey(cid, threadRoot)
|
|
1713
|
+
const threadOwned = !!controlKey && memory.has(controlKey, 'active')
|
|
1714
|
+
const mentionKeys = mentionDedupeKeys(msg, cid)
|
|
1715
|
+
const sourceKey = `mention:${cid ?? '?'}:${mentionKeys.idKey || mentionKeys.signatureKey || threadRoot || Date.now()}`
|
|
1708
1716
|
// De-dupe: the same mention re-delivered (reconnect replay / dup fan-out) must
|
|
1709
1717
|
// NOT trigger a second reply. Key by message id, or a channel+text signature
|
|
1710
|
-
// when the payload carries no id.
|
|
1711
|
-
|
|
1712
|
-
|
|
1718
|
+
// when the payload carries no id. Older recipient filtering acknowledged
|
|
1719
|
+
// some owned-thread follow-ups before routing them. If such a message is
|
|
1720
|
+
// replayed, its replay key exists but its received ledger entry does not;
|
|
1721
|
+
// admit it once so the corrected ownership rule can recover the message.
|
|
1722
|
+
if (!raw._mentionAlreadyMarked && markMentionHandled(msg, cid)) {
|
|
1723
|
+
if (!threadOwned || memory.has(sourceKey, 'received')) { log('agent:mention (dup) — skipped'); return }
|
|
1724
|
+
log('agent:mention replay recovered for active owned thread')
|
|
1725
|
+
}
|
|
1726
|
+
const target = classifyConversationTarget(msg, [...selfAliases], { threadOwned })
|
|
1713
1727
|
if (target.action === 'ignore') {
|
|
1714
1728
|
log('agent:mention ignored before model start (' + target.reason + ')')
|
|
1715
1729
|
// A redirect or another agent taking over invalidates any older work or
|
|
@@ -1720,8 +1734,6 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1720
1734
|
}
|
|
1721
1735
|
return
|
|
1722
1736
|
}
|
|
1723
|
-
const mentionKeys = mentionDedupeKeys(msg, cid)
|
|
1724
|
-
const sourceKey = `mention:${cid ?? '?'}:${mentionKeys.idKey || mentionKeys.signatureKey || threadRoot || Date.now()}`
|
|
1725
1737
|
if (target.action === 'stand_down') {
|
|
1726
1738
|
if (cid != null && threadRoot != null) {
|
|
1727
1739
|
cancelThread(cid, threadRoot, text || 'Explicit stand-down')
|