openvisio-agent 0.19.8 → 0.19.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -58,7 +58,7 @@ 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.
61
+ Model selections are stable. Exact IDs (including reasoning effort) are preserved. An unsuffixed ACP model may resolve once to the same model with `medium` effort; the watcher saves that exact selection. Unavailable selections produce an explicit error rather than switching generations or families. Claude versioned IDs are preserved, with automatic family fallbacks disabled. Codex uses the configured chat model for replies.
62
62
 
63
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.
64
64
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.19.8",
3
+ "version": "0.19.10",
4
4
  "description": "Connect Claude Code, Codex, or OpenCode to an OpenVisio team — MCP tools + optional autonomy — in one command.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -77,7 +77,7 @@ const assertions = [
77
77
  ['MCP requests have a deadline including response bodies', mcpHttp.includes('controller.abort()') && mcpHttp.includes('const body = await res.text()')],
78
78
  ['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:')],
79
79
  ['Codex and OpenCode use warm scoped Mastra ACP sessions', watcher.includes('createMastraAcpRunner') && watcher.includes('const replyRunners = new Map()') && watcher.includes('replyRunnerFor(item.delivery)') && mastraHarness.includes('new AcpAgentClass') && mastraHarness.includes('persistSession: true') && mastraHarness.includes("runtime: 'mastra-acp'")],
80
- ['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'" )],
80
+ ['runtime models are selected explicitly before work starts', mastraHarness.includes('getAvailableModels()') && mastraHarness.includes('resolveAvailableModel(requestedModel') && mastraHarness.includes('await acp.setModel(resolution.selected)') && modelSelection.includes('automatic model substitution is disabled') && watcher.includes('if (changed) persistModel()')],
81
81
  ['one watcher owns isolated concurrent work and thread-scoped serial reply runtimes', watcher.includes('MAX_CONCURRENT_WORKERS = 3') && watcher.includes('createWorkRunner(item.control, item.workdir)') && watcher.includes('const replyRunners = new Map()') && watcher.includes('while (replyRunners.size > 8)')],
82
82
  ['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)')],
83
83
  ['work and reply cancellation targets are isolated', watcher.includes('item.control.cancelled = true') && watcher.includes('item.control.runner?.cancelCurrent')],
@@ -128,7 +128,7 @@ const assertions = [
128
128
  ['policy blocker is surfaced to the user', watcher.includes('reportPolicyBlock(prompt, activeTaskRef, result.policyBlock, delivery)') && watcher.includes("Action required: I'm blocked")],
129
129
  ['blocker routing carries explicit task identity', watcher.includes('const activeTaskRef = taskRef') && watcher.includes('taskRef: activeTaskRef')],
130
130
  ['reply discovery failures stay scoped while failed mutations fail closed', watcher.includes('blockingReplyMcpErrors(result?.mcpErrors)') && watcher.includes('preserving the scoped model reply') && events.includes('export function blockingReplyMcpErrors')],
131
- ['pending-ticket questions use watcher-owned MCP reads without a model cycle', watcher.includes('conversationAsksPendingTickets(text)') && watcher.includes('pending-ticket question -> watcher-owned MCP lookup') && watcher.includes("callMcpReadWithRetry('list_tasks'") && !watcher.includes("callMcpReadWithRetry('list_agents'")],
131
+ ['pending-ticket questions use watcher-owned MCP reads without a chat model cycle', watcher.includes('conversationAsksPendingTickets(text)') && watcher.includes('pending-ticket question -> watcher-owned MCP lookup') && watcher.includes("callMcpReadWithRetry('list_tasks'") && watcher.includes("if (selfAgentId == null) {\n const agentsData = toolData(await callMcpReadWithRetry('list_agents'))")],
132
132
  ['chat ACP allows opaque MCP approvals behind a strict read-only proxy', mastraHarness.includes('export function acpPermissionResponse') && mastraHarness.includes('opaqueMcpApproval') && mastraHarness.includes('OPENVISIO_CODEX_ALLOWED_TOOLS') && codexProxy.includes('export function toolAllowed') && codexProxy.includes('allowedTools.has(name)')],
133
133
  ['all runtime blockers have a delivery path', watcher.includes('publishBlocker') && watcher.includes('WORK_CYCLE_BLOCKED') && watcher.includes('COORDINATION_CYCLE_BLOCKED')],
134
134
  ['ticket blocker cannot self-authorize', watcher.includes("ticketNotice = `I'm paused") && watcher.includes('publishBlocker({ prompt, taskRef, delivery, notice, ticketNotice, pause: true })') && !watcher.includes('test(approvalText)')],
@@ -0,0 +1,32 @@
1
+ // Assignment requests are routed by the watcher, before the restricted chat
2
+ // runtime can mistake its own tool limits for the coding worker's capabilities.
3
+ export function assignmentStatusOnly(value) {
4
+ return /\b(?:do not|don't|dont|stop|cancel|stand down|status only|just (?:list|check|tell|show)|only (?:list|check|tell|show))\b/i.test(String(value || ''))
5
+ }
6
+
7
+ export function assignmentRequest(value) {
8
+ const text = String(value || '').replace(/\s+/g, ' ').trim()
9
+ if (assignmentStatusOnly(text)) return null
10
+ const slugs = [...new Set((text.match(/\b[a-z][a-z0-9]*-\d+\b/gi) || []).map((slug) => slug.toUpperCase()))]
11
+ const tickets = /\b(?:tickets?|tasks?|assignments?|backlog)\b/i.test(text) || slugs.length > 0
12
+ const action = /\b(?:start|begin|resume|continue|implement|fix|finish|complete|handle|execute|dispatch|pick\s+up|work\s+(?:on|through))\b/i.test(text)
13
+ const assigned = /\b(?:assigned|gave)\s+(?:\S+\s+){0,3}you\b|\byou\s+have\b.*\b(?:pending|assigned)\b/i.test(text)
14
+ return tickets && (action || assigned) ? { slugs } : null
15
+ }
16
+
17
+ export async function routeAssignments({ request, loadTickets, handleTask, isCancelled = () => false }) {
18
+ const tickets = await loadTickets()
19
+ const selected = tickets.filter((ticket) => !request.slugs.length || request.slugs.includes(ticket.slug))
20
+ // A project-scoped slug must resolve uniquely before any work is started.
21
+ for (const slug of request.slugs) {
22
+ if (selected.filter((ticket) => ticket.slug === slug).length !== 1) {
23
+ throw new Error(`I couldn't uniquely resolve ${slug} among my open assignments.`)
24
+ }
25
+ }
26
+ const actionable = selected.filter((ticket) => !ticket.awaitingReview)
27
+ for (const ticket of actionable) {
28
+ if (isCancelled()) return
29
+ await handleTask('task:assigned', { task: { id: ticket.id, project_id: ticket.projectId } })
30
+ }
31
+ return actionable.length
32
+ }
@@ -98,7 +98,7 @@ export function createMastraAcpRunner({
98
98
  const headers = Object.entries(mcpHeaders).filter(([, value]) => value != null && value !== '').map(([name, value]) => ({ name, value: String(value) }))
99
99
  const disabledMcpTools = Array.isArray(cycleOptions.disabledMcpTools) ? cycleOptions.disabledMcpTools.filter(Boolean) : []
100
100
  const mcpServers = !mcpUrl ? [] : agent === 'codex' ? [{
101
- name: 'openvisio-team', command: process.execPath, args: [proxyPath], env: [
101
+ name: 'openvisio-team-watcher', command: process.execPath, args: [proxyPath], env: [
102
102
  { name: 'OPENVISIO_CODEX_MCP_URL', value: mcpUrl },
103
103
  { name: 'OPENVISIO_CODEX_API_KEY', value: String(mcpHeaders['x-agent-api-key'] || '') },
104
104
  { name: 'OPENVISIO_CODEX_IDENTIFIER', value: String(mcpHeaders['x-agent-identifier'] || '') },
@@ -119,6 +119,10 @@ export function createMastraAcpRunner({
119
119
  env: agent === 'codex' ? {
120
120
  INITIAL_AGENT_MODE: canCode ? 'agent' : 'read-only',
121
121
  NO_BROWSER: '1',
122
+ // codex-acp otherwise keeps a pre-existing openvisio-team server and
123
+ // silently discards this session's authenticated stdio bridge.
124
+ DISABLE_MCP_CONFIG_FILTERING: 'true',
125
+ CODEX_CONFIG: JSON.stringify({ 'mcp_servers.openvisio-team.enabled': false }),
122
126
  } : {},
123
127
  session: {
124
128
  cwd: cycleCwd,
@@ -161,9 +165,9 @@ export function createMastraAcpRunner({
161
165
  if (requestedModel !== negotiatedRequested) {
162
166
  const availableModels = await acp.getAvailableModels()
163
167
  const resolution = resolveAvailableModel(requestedModel, availableModels)
164
- selectedModel = resolution.selected
165
- if (selectedModel) {
166
- await acp.setModel(selectedModel)
168
+ if (resolution.selected) {
169
+ await acp.setModel(resolution.selected)
170
+ selectedModel = resolution.selected
167
171
  if (selectedModel !== requestedModel) log(`model ${requestedModel} resolved to available ${selectedModel} [${resolution.reason}]`)
168
172
  } else {
169
173
  log(`model ${requestedModel} could not be negotiated because ${agent} did not advertise selectable models; using its session default`)
@@ -209,7 +213,9 @@ export function createMastraAcpRunner({
209
213
  const after = repositorySnapshot(canCode ? cycleCwd : '')
210
214
  if (before !== after) { didCode = true; didRepoMutation = true }
211
215
  didResultMessage = didChannelMessage && didRepoMutation
212
- log(`${agent} cycle done via Mastra ACP (ok)`)
216
+ const redact = (value) => Object.values(mcpHeaders).filter(Boolean).reduce((safe, secret) => safe.split(String(secret)).join('[redacted]'), String(value))
217
+ for (const [name, detail] of errors) log(`MCP ${name} failed: ${redact(detail)}`)
218
+ log(`${agent} cycle done via Mastra ACP (${errors.size ? 'tool-errors' : 'ok'})`)
213
219
  return {
214
220
  type: 'result', subtype: 'ok', runtime: 'mastra-acp', model: selectedModel || null, requestedModel: requestedModel || null, outputText: outputText.trim(),
215
221
  mcpCalls: [...calls], mcpErrors: [...errors.keys()], mcpErrorDetails: Object.fromEntries(errors),
@@ -219,7 +225,9 @@ export function createMastraAcpRunner({
219
225
  } catch (error) {
220
226
  const canceled = controller.signal.aborted
221
227
  const subtype = timedOut ? 'timeout' : canceled ? 'canceled' : 'error'
222
- log(`${agent} cycle done via Mastra ACP (${subtype}${!canceled && error?.message ? ': ' + clean(error.message) : ''})`)
228
+ const errorDetail = [error?.message, error?.data ? json(error.data) : ''].filter(Boolean).join(': ')
229
+ const safeError = Object.values(mcpHeaders).filter(Boolean).reduce((safe, secret) => safe.split(String(secret)).join('[redacted]'), errorDetail)
230
+ log(`${agent} cycle done via Mastra ACP (${subtype}${!canceled && safeError ? ': ' + clean(safeError, 1200) : ''})`)
223
231
  invalidate()
224
232
  return {
225
233
  type: 'result', subtype, runtime: 'mastra-acp', model: selectedModel || null, requestedModel: requestedModel || null, outputText: outputText.trim(),
@@ -40,10 +40,18 @@ function similarity(requested, candidate, preferredEffort) {
40
40
  export function resolveAvailableModel(requestedValue, availableModels = [], { preferredEffort = DEFAULT_EFFORT } = {}) {
41
41
  const requested = parts(requestedValue)
42
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: [] }
43
+ if (!choices.length && requested.raw) return { requested: requested.raw, selected: requested.raw, reason: 'explicit-unadvertised', available: [] }
44
+ if (!choices.length) return { requested: '', selected: '', reason: 'runtime-default', available: [] }
44
45
  if (!requested.raw) return { requested: '', selected: '', reason: 'runtime-default', available: choices }
45
46
 
46
- const ranked = choices.map((id, index) => {
47
+ const compatible = choices.filter((id) => {
48
+ const candidate = parts(id)
49
+ return candidate.id === requested.id || (!requested.effort &&
50
+ candidate.leaf === requested.leaf && (!requested.provider || candidate.provider === requested.provider) &&
51
+ (!candidate.effort || candidate.effort === preferredEffort))
52
+ })
53
+ if (!compatible.length) throw new Error(`Configured model "${requested.raw}" is unavailable; automatic model substitution is disabled.`)
54
+ const ranked = compatible.map((id, index) => {
47
55
  const candidate = parts(id)
48
56
  return { id, index, score: similarity(requested, candidate, preferredEffort) }
49
57
  }).sort((a, b) => b.score - a.score || a.index - b.index)
@@ -59,17 +67,9 @@ export function resolveAvailableModel(requestedValue, availableModels = [], { pr
59
67
  const CLAUDE_FAMILIES = ['sonnet', 'opus', 'haiku', 'fable']
60
68
 
61
69
  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)
70
+ return text(requestedValue)
66
71
  }
67
72
 
68
73
  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
+ return []
74
75
  }
75
-
package/src/watch.mjs CHANGED
@@ -18,6 +18,7 @@ import { createMcpHttpClient } from './mcp-http.mjs'
18
18
  import { buildOpencodeConfig, opencodeRuntimeLayout } from './opencode-config.mjs'
19
19
  import { buildCodexMcpOverride } from './codex-config.mjs'
20
20
  import { createCycleQueue } from './cycle-queue.mjs'
21
+ import { assignmentRequest, assignmentStatusOnly, routeAssignments } from './assignment-routing.mjs'
21
22
  import { modelProcessOptions, stopModelProcess } from './process-lifecycle.mjs'
22
23
  import { createMastraAcpRunner } from './mastra-harness.mjs'
23
24
  import { claudeFallbackModels, resolveClaudeModel } from './model-selection.mjs'
@@ -261,10 +262,9 @@ export async function runWatch({ flags }) {
261
262
  const workdir = chatOnly ? '' : (explicitWorkdir || (saved && (saved.workspace || saved.workdir)) || DEFAULT_WORKSPACE)
262
263
  if (workdir) { try { mkdirSync(workdir, { recursive: true }) } catch { /* best-effort; spawn will surface a real problem */ } }
263
264
 
264
- // Codex uses Sol for every watcher cycle. Reliability and consistent MCP/tool
265
- // behaviour take priority over maintaining a cheaper coordination lane.
266
- const defaultModel = agent === 'claude' ? 'sonnet' : agent === 'codex' ? 'gpt-5.6-sol' : ''
267
- const defaultChatModel = agent === 'claude' ? 'haiku' : agent === 'codex' ? 'gpt-5.6-sol' : ''
265
+ // Use a specific Codex ID; configured chat and code tiers remain independent.
266
+ const defaultModel = agent === 'claude' ? 'sonnet' : agent === 'codex' ? 'gpt-5.6-sol[medium]' : ''
267
+ const defaultChatModel = agent === 'claude' ? 'haiku' : agent === 'codex' ? 'gpt-5.6-sol[medium]' : ''
268
268
  const model = String(flags.model || (saved && saved.model) || defaultModel)
269
269
  const chatModel = String(flags['chat-model'] || (saved && saved.chatModel) || defaultChatModel)
270
270
 
@@ -1076,6 +1076,12 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1076
1076
  }
1077
1077
 
1078
1078
  const loadPendingTickets = async () => {
1079
+ if (selfAgentId == null) {
1080
+ const agentsData = toolData(await callMcpReadWithRetry('list_agents'))
1081
+ const self = (Array.isArray(agentsData.agents) ? agentsData.agents : []).find((a) => String(a.identifier || a.slug || '') === identifier)
1082
+ if (!self?.id) throw new Error('list_agents did not return this BYO agent')
1083
+ rememberSelfAgent(self)
1084
+ }
1079
1085
  const projectsData = toolData(await callMcpReadWithRetry('list_projects'))
1080
1086
  const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
1081
1087
  const groups = await Promise.all(projects.filter((project) => project?.id != null).map(async (project) => {
@@ -1090,12 +1096,16 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1090
1096
  const typesData = typesResult ? toolData(typesResult) : {}
1091
1097
  const types = Array.isArray(typesData.types) ? typesData.types : Array.isArray(typesData.task_types) ? typesData.task_types : Array.isArray(typesData.taskTypes) ? typesData.taskTypes : []
1092
1098
  const doneIds = new Set(types.filter((type) => /\b(?:done|complete|completed|closed|cancelled|canceled|archived|resolved)\b/i.test(String(type.name || ''))).map((type) => Number(type.id)))
1099
+ const reviewIds = new Set(types.filter((type) => /\b(?:review|test|testing|qa|quality\s+assurance|verification|approval)\b/i.test(String(type.name || ''))).map((type) => Number(type.id)))
1093
1100
  return (Array.isArray(tasksData.tasks) ? tasksData.tasks : []).filter((task) => {
1094
1101
  const assignedId = Number(taskAgentId(task) ?? task.agent?.id ?? task.assigned_agent?.id)
1095
1102
  const assignedIdentifier = String(task.agent?.identifier ?? task.agent?.slug ?? task.assigned_agent?.identifier ?? task.assigned_agent?.slug ?? '')
1096
1103
  const assignedHere = (selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier
1097
1104
  return assignedHere && !taskIsCompleted(task, doneIds)
1098
1105
  }).map((task) => ({
1106
+ id: task.id,
1107
+ projectId: project.id,
1108
+ awaitingReview: taskIsAwaitingReview(task, reviewIds),
1099
1109
  slug: ticketDisplaySlug(task),
1100
1110
  title: String(task.title || 'Untitled ticket'),
1101
1111
  state: String(task.type?.name ?? task.task_type?.name ?? task.status ?? '').trim(),
@@ -1105,9 +1115,17 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1105
1115
  return groups.flat()
1106
1116
  }
1107
1117
 
1108
- const answerPendingTickets = async ({ delivery, who }) => {
1118
+ const answerPendingTickets = async ({ delivery, who, text }) => {
1109
1119
  try {
1110
1120
  const tickets = await loadPendingTickets()
1121
+ // Assignments already authorize pickup. A board lookup should not leave
1122
+ // discovered work idle until a second human nudge arrives.
1123
+ if (canCode && !assignmentStatusOnly(text)) {
1124
+ await routeAssignments({
1125
+ request: { slugs: [] }, loadTickets: async () => tickets, handleTask: handleTaskSignal,
1126
+ isCancelled: () => memory.has(threadControlKey(delivery?.channelId, delivery?.parentId), 'cancelled'),
1127
+ })
1128
+ }
1111
1129
  const prefix = who ? `@${who} ` : ''
1112
1130
  const details = tickets.slice(0, 8).map((ticket) => `${ticket.slug || ticket.title}${ticket.slug ? ` “${ticket.title}”` : ''}${ticket.state ? ` (${ticket.state})` : ''}${ticket.project ? ` in ${ticket.project}` : ''}`)
1113
1131
  const extra = tickets.length > details.length ? `, plus ${tickets.length - details.length} more` : ''
@@ -1385,7 +1403,12 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1385
1403
  const [tasksData, typesData, activityData] = await Promise.all([
1386
1404
  callMcpTool('list_tasks', { project_id: project.id }).then(toolData),
1387
1405
  callMcpTool('list_task_types', { project_id: project.id }).then(toolData),
1388
- callMcpTool('list_activity', { project_id: project.id }).then(toolData),
1406
+ // Chat history is optional for assignment pickup. An unavailable
1407
+ // activity feed must not strand otherwise verified pending tickets.
1408
+ callMcpTool('list_activity', { project_id: project.id }).then(toolData).catch((error) => {
1409
+ log('activity reconciliation unavailable: ' + (error?.message || error))
1410
+ return {}
1411
+ }),
1389
1412
  ])
1390
1413
  const taskTypes = Array.isArray(typesData.types) ? typesData.types : Array.isArray(typesData.task_types) ? typesData.task_types : Array.isArray(typesData.taskTypes) ? typesData.taskTypes : []
1391
1414
  const doneIds = new Set(taskTypes.filter((t) => /\b(?:done|complete|completed|closed|cancelled|canceled|archived|resolved)\b/i.test(String(t.name || ''))).map((t) => Number(t.id)))
@@ -1562,7 +1585,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1562
1585
  const prompt = (ctx.length ? ctx.join('\n') + '\n\n' : '') + (recalled ? recalled + '\n\n' : '') + baseFor(kind, delivery)
1563
1586
  // Chat-shaped cycles (mentions/intro) may run on the cheaper chat model; code
1564
1587
  // work (full/sweep) uses the main model.
1565
- const useModel = agent === 'codex' ? codeModel : kind === 'full' ? codeModel : liteModel
1588
+ const useModel = kind === 'full' ? codeModel : liteModel
1566
1589
  log('running ' + kind + ' cycle…' + (ctx.length ? ' (' + ctx.length + ' event' + (ctx.length === 1 ? '' : 's') + ')' : '') + (useModel ? ' [' + useModel + ']' : ''))
1567
1590
  // Presence notifications are intentionally disabled; durable messages and
1568
1591
  // ticket transitions are the only user-visible progress signals.
@@ -1584,6 +1607,14 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1584
1607
  }
1585
1608
  if (cycleControl.cancelled) return
1586
1609
  const result = await runner.runCycle(prompt, useModel, runnerOptions)
1610
+ if (result?.model && result.model !== useModel) {
1611
+ // Persist only a successfully selected model and only if the user has
1612
+ // not changed that tier while this cycle was running.
1613
+ let changed = false
1614
+ if (codeModel === useModel) { codeModel = result.model; changed = true }
1615
+ if (liteModel === useModel) { liteModel = result.model; changed = true }
1616
+ if (changed) persistModel()
1617
+ }
1587
1618
  let completionResult = result
1588
1619
  if (cycleControl.cancelled || result?.subtype === 'canceled') {
1589
1620
  log(laneName + ' cycle cancelled; no blocker or reply will be published')
@@ -1686,9 +1717,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1686
1717
  const x = String(s || '').toLowerCase().replace(/[.,!?]+$/, '')
1687
1718
  if (x === 'opus' || x === 'sonnet' || x === 'haiku') return x
1688
1719
  if (/^claude-[a-z0-9.\-\[\]]+$/i.test(x)) return x
1689
- if (/^(?:gpt|codex|o[1-9])[a-z0-9.\-:]*$/i.test(x)) return x
1720
+ if (/^(?:gpt|codex|o[1-9])[a-z0-9.\-:]*(?:\[(?:low|medium|high|xhigh|max|ultra)\])?$/i.test(x)) return x
1690
1721
  // opencode models are provider/model (e.g. anthropic/claude-sonnet-4, openai/gpt-4o).
1691
- return /^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9.\-:]*$/i.test(x) ? x : null
1722
+ return /^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9.\-:]*(?:\[(?:low|medium|high|xhigh|max|ultra)\])?$/i.test(x) ? x : null
1692
1723
  }
1693
1724
  // Optional target tier: "chat"/"lite" → chat cycles only, "code"/"full" → code
1694
1725
  // cycles only, absent → both.
@@ -1868,9 +1899,29 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1868
1899
  }
1869
1900
  return
1870
1901
  }
1902
+ const assignment = assignmentRequest(text)
1903
+ if (assignment) {
1904
+ const delivery = conversationDelivery('assignment-routing')
1905
+ const isCancelled = () => !!controlKey && memory.has(controlKey, 'cancelled')
1906
+ // Enqueue each verified ticket separately, using the same ownership,
1907
+ // revision and queue dedupe gates as live assignment events.
1908
+ void (async () => {
1909
+ try {
1910
+ if (!canCode) throw new Error('This watcher is configured with --chat-only and has no coding worker. Enable its coding workspace to execute assigned tickets.')
1911
+ const count = await routeAssignments({ request: assignment, loadTickets: loadPendingTickets, handleTask: handleTaskSignal, isCancelled })
1912
+ if (count === 0 && delivery && !isCancelled()) {
1913
+ await postMessageOnce({ ...delivery, content: `${who ? `@${who} ` : ''}I have no open assignments ready for implementation; tickets awaiting review or testing stay in their current stage.` })
1914
+ }
1915
+ } catch (error) {
1916
+ log('assignment routing failed: ' + (error?.message || error))
1917
+ if (delivery && !isCancelled()) await postMessageOnce({ ...delivery, content: `${who ? `@${who} ` : ''}I couldn't route the assigned work: ${error?.message || error}` })
1918
+ }
1919
+ })().catch((error) => log('assignment routing delivery failed: ' + (error?.message || error)))
1920
+ return
1921
+ }
1871
1922
  if (cid != null && conversationAsksPendingTickets(text)) {
1872
1923
  log('pending-ticket question -> watcher-owned MCP lookup')
1873
- void answerPendingTickets({ delivery: conversationDelivery('pending-tickets'), who })
1924
+ void answerPendingTickets({ delivery: conversationDelivery('pending-tickets'), who, text })
1874
1925
  return
1875
1926
  }
1876
1927
  log('agent:mention in channel ' + (cid != null ? cid : '?') + (threadRoot != null ? ' (thread ' + threadRoot + ')' : ''))