openvisio-agent 0.25.12 → 0.25.14

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/src/watch.mjs CHANGED
@@ -18,7 +18,7 @@ import { createContextService } from './context-service.mjs'
18
18
  import { contextToolGuide } from './context-tools.mjs'
19
19
  import { loadProjectTickets, needsWorkContinuation } from './work-recovery.mjs'
20
20
  import { toolWithoutCredentialInputs } from './codex-mcp-proxy.mjs'
21
- import { compactAgentMessage } from './tool-policy.mjs'
21
+ import { compactAgentMessage, ITEM_REFERENCE_GUIDANCE } from './tool-policy.mjs'
22
22
  import { repositoryHasPrPushAuthorization, configurePrPublishing } from './pr-push.mjs'
23
23
  import { createMcpHttpClient } from './mcp-http.mjs'
24
24
  import { createCycleQueue } from './cycle-queue.mjs'
@@ -30,6 +30,8 @@ import { releaseAuthorizedPause } from './authorization-resume.mjs'
30
30
  import { dedicatedChannel, repliesAfterSource } from './channel-routing.mjs'
31
31
  import { createTaskTypeLookup, loadProjectTaskTypes } from './task-types.mjs'
32
32
  import { createThreadContextResolver } from './thread-context.mjs'
33
+ import { compileConversationContext, createConversationReader, conversationAuthor, checkedConversationResult } from './conversation-context.mjs'
34
+ import { admitPeerTurn } from './peer-turns.mjs'
33
35
  import { assignmentRequest, assignmentStatusOnly, routeAssignments } from './assignment-routing.mjs'
34
36
  import { modelProcessOptions, stopModelProcess } from './process-lifecycle.mjs'
35
37
  import { createMastraAcpRunner } from './mastra-harness.mjs'
@@ -38,6 +40,7 @@ import { createAgentJournal } from './agent-journal.mjs'
38
40
  import { readModelSettings } from './model-settings.mjs'
39
41
  import { startStudioInBackground } from './studio-cli.mjs'
40
42
  import { toolData } from './team-data.mjs'
43
+ import { REPORT_ROUTING_PROMPT, reportChannelSelection } from './report-routing.mjs'
41
44
  import { runtimeProvider, supportedRuntimeList } from './runtime-providers.mjs'
42
45
 
43
46
  function runtimeJournal(options, supplied) {
@@ -80,6 +83,7 @@ function findTicketWorktree(workspaceRoot, ticketId) {
80
83
  // answering a question meant for a DIFFERENT agent, and claiming something works
81
84
  // then walking it back.
82
85
  const REPLY_DISCIPLINE = [
86
+ ITEM_REFERENCE_GUIDANCE,
83
87
  'REPLY DISCIPLINE — read the recent messages FIRST, then decide whether to speak at all:',
84
88
  ' • FIRST-PERSON VOICE. Speak as yourself: use “I”, “I\'m”, and “my”. Never refer to yourself by your agent name or in the third person, and never restate your own name in introductions, acknowledgements, progress, blockers, or results. The app already shows who sent the message. Sound like a warm, accountable teammate, not a status bot.',
85
89
  ' • IS IT FOR YOU? Act ONLY on messages addressed to YOU — an @mention of your exact name, a direct question to you, or a reply to something YOU said or did. If a DIFFERENT agent or person was @mentioned or asked to do something, STAY OUT: do not answer for them and do not pick up their task. When it is not yours, posting nothing is the correct move.',
@@ -89,9 +93,9 @@ const REPLY_DISCIPLINE = [
89
93
  ' • USE RECALL, NEVER INVENT IT. Before answering a context-dependent question, search the visible thread and use any available history, search, docs, or recall tools. Reuse verified context instead of asking the user to repeat it. If no record exists, say plainly "I don\'t have a record of that". Never fabricate past events, conversations, results, links, PR numbers, deploy URLs, or figures.',
90
94
  ' • LOOK UP ASSIGNED WORK. If someone says they assigned you a task, asks which task is yours, or asks for its status, check the live board yourself with list_projects + list_tasks and then get_ticket as needed. Use list_agents only if assignment data requires a numeric identity lookup. Match assignments to your authenticated agent identity. Do not ask the teammate for a project slug, ticket slug, or numeric id before trying those MCP tools; ask only if the live lookup fails or returns genuinely ambiguous matches.',
91
95
  ' • ASSIGNEE IDS ARE HUMAN IDS. On task mutations, assignee_id means a human user/member. agent_id means a registered agent. Never put an agent id in assignee_id, never put a human id in agent_id, and never send both. To assign work to yourself or another agent, resolve the registered agent and use agent_id.',
92
- ' • TICKET SLUGS, NEVER DATABASE IDS. In every human-facing channel message, ticket comment, PR description, summary, blocker, and result, reference a ticket by the exact project-scoped slug returned by get_ticket/list_tasks (for example, `OVS-57`). Numeric project_id and ticket_id values are internal MCP arguments only: never write `#57`, `ticket 57`, or expose a database id to teammates. If the backend omits the slug, use the ticket title or say “the ticket”; do not invent a slug.',
96
+ ' • TICKET SLUGS, NEVER DATABASE IDS. In every human-facing channel message, ticket comment, PR description, summary, blocker, and result, reference a ticket by the exact project-scoped slug returned by get_ticket/list_tasks (for example, `OVS-57`). Numeric project_id and ticket_id values belong only in MCP arguments or verified link destinations: never write `#57`, `ticket 57`, or expose a database id to teammates. If the backend omits the slug, use the ticket title or say “the ticket”; do not invent a slug.',
93
97
  ' • CLOSE CONCERNS. Never leave a concern, direct question, correction, or blocker addressed to you without a clear response. Acknowledge the concern, act if you can, then report the verified result. If blocked, name the blocker and the exact next action or owner in one message.',
94
- ' • HONOR CHANNEL DESTINATIONS. Reply to an existing request in its original thread. When a teammate explicitly asks you to send, post, announce, or share something in another channel, resolve that exact destination from live list_channels and post there. Use your dedicated agent channel for fresh top-level results, blockers, and introductions only when no other destination was requested. Never invent a channel id or silently substitute a different channel.',
98
+ ' • HONOR CHANNEL DESTINATIONS. Reply to an existing request in its original thread. When a teammate explicitly asks you to send, post, announce, or share something in another channel, resolve that exact destination from live list_channels and post there. For a task result without a source thread or explicit destination, choose the existing channel that best fits the project, subject, and audience; a dedicated agent channel is not required. Never invent a channel id or silently substitute a different explicitly requested channel.',
95
99
  ' • KEEP MESSAGES COMPACT. Lead with the answer, result, decision, or blocker. Default to 1-4 short sentences or at most 6 brief bullets, normally under 600 characters and never over 1,200. Do not restate the prompt, narrate routine tool use, dump raw logs, or repeat context already visible in the thread. Task descriptions may use up to 2,400 characters when acceptance details require it.',
96
100
  ' • END AGENT-TO-AGENT TURNS. Answer one concrete peer-agent request once, without a reciprocal question, invitation, thank-you exchange, or status echo. Then wait for a human or a genuinely new human-directed request. Do not keep a conversation alive merely because another agent replied.',
97
101
  ' • WRITE PLAINLY. Prefer short sentences, commas, periods, and colons. Avoid em dashes except when reproducing quoted text.',
@@ -135,7 +139,7 @@ const CODE_CHARTER = [
135
139
  '',
136
140
  'WORK ETHIC — how a reliable teammate behaves (this is the difference between useful and ignored):',
137
141
  ' 1. CLOSE THE LOOP in THIS cycle. Never say "I\'ll do X" and stop. If you commit to something, do it NOW — the human must never have to remind you to circle back. Sending an intent or progress message is not a stop condition: continue using tools and send the verified result or blocker afterward.',
138
- ' 2. FINISH, then REPORT. Update the ticket when the actual work calls for a state change. Reply in a supplied human source thread when one exists. For backlog-assigned work, do not call post_message yourself: the watcher publishes your final response in the dedicated agent channel.',
142
+ ' 2. FINISH, then REPORT. Update the ticket when the actual work calls for a state change. Reply in a supplied human source thread when one exists. For backlog-assigned work, do not call post_message yourself: the watcher asks you to choose a suitable existing channel and publishes your final response there.',
139
143
  ' 3. Be honest and specific. Never invent progress. If you are genuinely blocked (missing repo, unclear spec, a failing tool), say exactly what you need in one message — that IS closing the loop.',
140
144
  ' 4. One final reply per request; answer several nudges together. A single concrete progress update is allowed during longer work, but it must be followed by the final result or blocker in the same cycle.',
141
145
  ' 5. RECOVER DEAD COMMAND SESSIONS. If write_stdin reports “Unknown process id”, that command session has already exited. Never poll the same process id again. Start a fresh exec_command when more work is required, then continue the task and verify the final state.',
@@ -155,7 +159,7 @@ const CODE_FULL = [
155
159
  'Use available tool discovery, resources, history, search, skills, and planning tools when useful. Prefer the repository knowledge graph when available. Recover from optional tool failures using alternatives; report only a limitation that actually prevents the requested outcome.',
156
160
  'A usable local clone is your primary code surface. Preserve unrelated work and use an isolated worktree when needed. Remote codebase tools are a fallback only when the repository cannot be obtained locally. For code changes, verify the result and use the authorized branch/PR flow when the request calls for it.',
157
161
  'You own ticket state: use update_ticket when its state should change, including review, completion, or a real blocker. Ending your turn does not mark the ticket done. Your native final response ends this cycle; it can report a result, ask a necessary question, or explain a blocker. Do not describe unperformed work as complete.',
158
- 'Follow the supplied source delivery rule. For backlog-only tickets, return your final response; the watcher delivers it once to your dedicated channel. Do not call post_message for that same result.',
162
+ 'Follow the supplied source delivery rule. For backlog-only tickets, return your final response; the watcher asks you to select the best-fitting existing channel and delivers the saved report once. Do not call post_message for that same result.',
159
163
  ].join('\n')
160
164
 
161
165
  const CODE_FAST = [
@@ -627,7 +631,6 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
627
631
  const replyStatusTargets = new Set()
628
632
  const activity = dependencies.activityReporter || createActivityReporter({
629
633
  backend, apiKey, identifier, log,
630
- sendFrame: (frame) => handle?.send?.(frame) === true,
631
634
  })
632
635
  const sendStatus = (channelId, state) => {
633
636
  const id = cycleContext.getStore()?.cycleId
@@ -1009,6 +1012,21 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1009
1012
  }).finally(() => { mcpToolDiscoveryPromise = null })
1010
1013
  return mcpToolDiscoveryPromise
1011
1014
  }
1015
+ const conversationReader = createConversationReader({ load: async (scope) => {
1016
+ const names = await discoverMcpTools()
1017
+ const ticket = scope.ticketId != null
1018
+ const tool = ticket ? 'list_task_comment_thread' : 'list_message_thread'
1019
+ if (!names.has(tool)) throw new Error('Conversation thread tool not advertised')
1020
+ const args = ticket ? { project_id: scope.projectId, ticket_id: scope.ticketId, comment_id: scope.threadId }
1021
+ : { channel_id: scope.channelId, message_id: scope.threadId }
1022
+ const [thread, surroundings] = await Promise.all([
1023
+ callMcpTool(tool, args).then(checkedConversationResult),
1024
+ !ticket && names.has('list_channel_messages')
1025
+ ? callMcpTool('list_channel_messages', { channel_id: scope.channelId, limit: 12, before_id: scope.threadId }).then(checkedConversationResult).catch(() => null)
1026
+ : null,
1027
+ ])
1028
+ return { thread, surroundings }
1029
+ } })
1012
1030
  const callOptionalTicketComment = async ({ projectId, ticketId, content, parentId }) => {
1013
1031
  let names
1014
1032
  try { names = await discoverMcpTools() }
@@ -1166,7 +1184,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1166
1184
  save: saveOwnThread,
1167
1185
  cancelled: (cid, root) => memory.has(threadControlKey(cid, root), 'cancelled'),
1168
1186
  identity: () => ({ id: selfAgentId, identifier, slug, name: slug }),
1169
- load: async (cid, root) => toolData(await callMcpTool('list_message_thread', { channel_id: Number(cid), message_id: Number(root) })),
1187
+ load: async (cid, root) => (await conversationReader.read({ channelId: Number(cid), threadId: Number(root) })).thread,
1170
1188
  })
1171
1189
  const peerTurnKey = (surface, ...parts) => `peer-turn:${surface}:${parts.map((value) => String(value ?? '?')).join(':')}`
1172
1190
  const ownTaskCommentKey = (projectId, ticketId, content) => {
@@ -1174,7 +1192,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1174
1192
  return `own-task-comment:${projectId}:${ticketId}:${normalized.length}:${normalized.slice(0, 120)}:${normalized.slice(-120)}`
1175
1193
  }
1176
1194
  const rememberPeerReply = (key, refs = {}) => {
1177
- if (key) memory.remember({ key, kind: 'peer-turn', state: 'replied', summary: 'Replied once to a peer agent; waiting for a human turn.', refs })
1195
+ if (key) memory.remember({ key, kind: 'peer-turn', state: 'replied', summary: 'Replied to a peer; bounded concrete follow-ups remain available.', refs, meta: memory.get(key)?.meta })
1178
1196
  }
1179
1197
  const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '', sourceMessageId, allowCancelled = false, peerLoopKey = '', isCancelled = () => false }) => {
1180
1198
  if (stopping) throw new Error('Watcher is stopping')
@@ -1194,6 +1212,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1194
1212
  const controlKey = threadControlKey(channelId, parentId)
1195
1213
  const suppressCancelled = () => {
1196
1214
  if (isCancelled()) return { posted: false, reason: 'cycle-cancelled' }
1215
+ if (sourceMessageId != null && memory.has(`deleted-message:${channelId}:${sourceMessageId}`, 'deleted')) return { posted: false, reason: 'source-deleted' }
1197
1216
  if (memory.has(deliveryKey, 'suppressed')) return { posted: false, reason: 'source-cancelled' }
1198
1217
  if (allowCancelled || !controlKey || !memory.has(controlKey, 'cancelled')) return null
1199
1218
  pendingReplyDeliveries.delete(deliveryKey)
@@ -1212,7 +1231,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1212
1231
  }
1213
1232
  const run = (async () => {
1214
1233
  if (parentId != null) {
1215
- const live = toolData(await callMcpTool('list_message_thread', { channel_id: Number(channelId), message_id: Number(parentId) }))
1234
+ const live = checkedConversationResult(await callMcpTool('list_message_thread', { channel_id: Number(channelId), message_id: Number(parentId) }))
1216
1235
  const plans = skipIfAnyAgentReply ? memory.recall({ channelId: Number(channelId), threadId: Number(parentId) }, 1000).filter(node => node.kind === 'work-plan' && node.meta?.sourceKey === sourceKey) : []
1217
1236
  const rendered = repliesAfterSource(renderedAgentMessages(live, { id: selfAgentId, identifier, slug, name: slug }), sourceMessageId).filter(row => !plans.some(plan => normalizeRenderedMessageText(plan.summary) === normalizeRenderedMessageText(row.content)))
1218
1237
  const duplicate = rendered.some((row) => normalizeRenderedMessageText(row.content) === normalizeRenderedMessageText(message))
@@ -1364,7 +1383,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1364
1383
  if (!Number.isFinite(projectId) || !Number.isFinite(ticketId)) return false
1365
1384
  const taskKey = `${projectId}:${ticketId}`
1366
1385
  if (String(result.outputText || '').trim()) {
1367
- pendingAgentResults.set(taskKey, { outputText: result.outputText, didChannelMessage: !!result.didChannelMessage })
1386
+ pendingAgentResults.set(taskKey, { outputText: result.outputText, didResultMessage: !!result.didResultMessage })
1368
1387
  pendingCompletionReports.add(taskKey)
1369
1388
  trimMap(pendingAgentResults); trimSeen(pendingCompletionReports); persistReplay()
1370
1389
  }
@@ -1397,9 +1416,9 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1397
1416
  try {
1398
1417
  const comment = await callOptionalTicketComment({ projectId, ticketId, content: report.content })
1399
1418
  if (comment.called) log('posted verified ticket comment for #' + ticketId)
1400
- else log('no ticket comment tool is exposed; completing ticket #' + ticketId + ' through its verified state and dedicated-channel report')
1419
+ else log('no ticket comment tool is exposed; completing ticket #' + ticketId + ' through its verified state and channel report')
1401
1420
  } catch (e) {
1402
- log('optional ticket comment failed for #' + ticketId + ': ' + (e?.message || e) + '; continuing with the verified dedicated-channel report')
1421
+ log('optional ticket comment failed for #' + ticketId + ': ' + (e?.message || e) + '; continuing with the verified channel report')
1403
1422
  }
1404
1423
  reportedTaskComments.add(report.key); trimSeen(reportedTaskComments); persistReplay()
1405
1424
  }
@@ -1408,19 +1427,52 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1408
1427
  persistReplay()
1409
1428
  return true
1410
1429
  }
1411
- // A model may already have replied when the task originated in a real source
1412
- // thread. Mark that verified completion as delivered instead of posting a
1413
- // second top-level copy.
1414
- if (result.didChannelMessage) {
1430
+ // Only a result posted after the work counts as completion delivery. An
1431
+ // earlier acknowledgement/progress message must not suppress the final.
1432
+ if (result.didResultMessage) {
1415
1433
  reportedCompletions.add(report.key); trimSeen(reportedCompletions)
1416
1434
  pendingCompletionReports.delete(taskKey); pendingAgentResults.delete(taskKey); persistReplay()
1417
1435
  log('completion for ticket #' + ticketId + ' was already posted by the work cycle')
1418
1436
  return true
1419
1437
  }
1420
- const channelId = await projectStatusChannel(projectId)
1421
- if (channelId == null || !Number.isFinite(channelId)) return false
1438
+ // The agent chooses the audience from live channels, independently of the
1439
+ // completed coding turn. Save its choice so retries never repeat the work.
1440
+ const channelData = toolData(await callMcpTool('list_channels'), 'list_channels')
1441
+ const channels = (Array.isArray(channelData.channels) ? channelData.channels : []).filter(channel => validBackendId(channel?.id))
1442
+ if (!channels.length) return false
1443
+ let channel = channels.find(row => Number(row.id) === result.channelId)
1444
+ if (!channel) {
1445
+ if (isCancelled() || stopping) return false
1446
+ const routingRunner = makeRunner({ ...runnerOptions, workdir: '', workspaceAvailable: false,
1447
+ systemPrompt: identityContext + '\n\n' + credNote + '\n\n' + REPORT_ROUTING_PROMPT,
1448
+ cfgKey: identifier + '-report-' + slugify(taskKey),
1449
+ })
1450
+ activeReplyRunners.add(routingRunner)
1451
+ try {
1452
+ const names = await discoverMcpTools()
1453
+ const selection = await routingRunner.runCycle(REPORT_ROUTING_PROMPT + '\n\n' + JSON.stringify({
1454
+ task: { projectId, ticketId, title: ticket.title, description: ticket.description },
1455
+ report: report.content,
1456
+ channels: channels.map(row => ({ id: Number(row.id), name: row.name, description: row.description, projectId: row.project_id, agentId: row.agent_id })),
1457
+ }), liteModel, {
1458
+ purpose: 'report-routing', cycleId: `${journal.runId}:report:${report.key}`,
1459
+ contextRefs: { projectId, ticketId },
1460
+ disabledMcpTools: [...names].filter(name => !['list_channels', 'get_channel', 'list_projects', 'get_project', 'get_ticket'].includes(name)),
1461
+ })
1462
+ if (!cycleSucceeded(selection) || isCancelled() || stopping) return false
1463
+ channel = reportChannelSelection(selection.outputText, channels)
1464
+ if (!channel) { log('Agent channel selection was not a live channel; saved report remains pending'); return false }
1465
+ result.channelId = Number(channel.id)
1466
+ persistReplay()
1467
+ emit('delivery.routed', { status: 'selected', channelId: result.channelId, ticket: { projectId, ticketId } })
1468
+ } finally {
1469
+ activeReplyRunners.delete(routingRunner)
1470
+ await routingRunner.close?.()
1471
+ }
1472
+ }
1473
+ const channelId = Number(channel.id)
1422
1474
  if (isCancelled()) return false
1423
- await postMessageOnce({ key: `completion:${report.key}`, projectId, channelId, content: report.content, sourceKey: memoryKey })
1475
+ await postMessageOnce({ key: `completion:${report.key}`, channelId, content: report.content, sourceKey: memoryKey })
1424
1476
  reportedCompletions.add(report.key); trimSeen(reportedCompletions)
1425
1477
  pendingCompletionReports.delete(taskKey); pendingAgentResults.delete(taskKey); persistReplay()
1426
1478
  memory.remember({ key: memoryKey, kind: 'ticket', state: 'reported', summary: report.content, refs: { projectId, ticketId, channelId }, meta: { reportKey: report.key, prUrl: report.prUrl } })
@@ -1532,8 +1584,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1532
1584
  if (taskIsCompleted(task, doneIds) || taskIsAwaitingReview(task, reviewIds)) {
1533
1585
  memory.remember({ key: `ticket:${project.id}:${task.id}`, kind: 'ticket', state: 'handoff', summary: task.title, refs: { projectId: project.id, ticketId: task.id } })
1534
1586
  if (pendingCompletionReports.has(taskKey)) {
1535
- const activityChannel = await projectStatusChannel(project.id)
1536
- try { await announceTaskCompletion({ projectId: project.id, ticketId: task.id, channelId: activityChannel }) }
1587
+ try { await announceTaskCompletion({ projectId: project.id, ticketId: task.id }) }
1537
1588
  catch (e) { log('completion report retry failed for ticket #' + task.id + ': ' + (e?.message || e)) }
1538
1589
  }
1539
1590
  if (blockedTasks.delete(taskKey)) { blockedTaskRepos.delete(taskKey); persistReplay() }
@@ -1678,6 +1729,12 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1678
1729
  : activeTaskRef ? { projectId: activeTaskRef.projectId, ticketId: activeTaskRef.ticketId } : {}
1679
1730
  let recalled = ''
1680
1731
  runnerOptions.contextRefs = memoryRefs
1732
+ if (delivery?.sourceMessage) {
1733
+ let snapshot, unavailable = !!delivery.contextUnavailable
1734
+ try { if (!unavailable) snapshot = await conversationReader.read(memoryRefs) }
1735
+ catch (error) { unavailable = true; log('Compiled conversation history unavailable: ' + (error?.message || error)) }
1736
+ ctx.push(compileConversationContext({ ...snapshot, source: delivery.sourceMessage, scope: memoryRefs, unavailable }))
1737
+ }
1681
1738
  try { recalled = await memory.context(memoryRefs) }
1682
1739
  catch (error) { log('Optional history unavailable; agent can retrieve context with its tools: ' + (error?.message || error)) }
1683
1740
  const capabilityContext = kind !== 'full' && canCode
@@ -1797,7 +1854,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1797
1854
  finishedTaskVersions.set(`${activeTaskRef.projectId}:${activeTaskRef.ticketId}`, verifiedTaskRevision)
1798
1855
  trimMap(finishedTaskVersions); persistReplay()
1799
1856
  }
1800
- if (activeTaskRef) {
1857
+ if (activeTaskRef && !delivery?.watcherOwned) {
1801
1858
  try {
1802
1859
  const delivered = await announceTaskCompletion(activeTaskRef, completionResult, () => cycleControl.cancelled)
1803
1860
  if (!delivered) {
@@ -1827,6 +1884,8 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1827
1884
  // Distill an event's WS payload into a directive the model can act on without
1828
1885
  // any extra lookup.
1829
1886
  const senderName = (m) => {
1887
+ const attributed = conversationAuthor(m || {})
1888
+ if (attributed.kind === 'agent' && attributed.name !== 'Unknown') return attributed.name
1830
1889
  const s = m && (m.taskSender || m.task_sender || m.sender || m.user || m.author || m.member || m.taskSenderAgent || m.task_sender_agent || m.senderAgent || m.sender_agent || m.agent)
1831
1890
  if (!s) return ''
1832
1891
  return (`${s.first_name || s.firstName || ''} ${s.last_name || s.lastName || ''}`.trim() || s.name || s.display_name || s.identifier || s.email || '')
@@ -1906,8 +1965,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1906
1965
  if (taskIsCompleted(ticket, typeSets.done) || taskIsAwaitingReview(ticket, typeSets.review)) {
1907
1966
  memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'handoff', summary: ticket.title, refs: { projectId, ticketId } })
1908
1967
  if (pendingCompletionReports.has(key)) {
1909
- const activityChannel = await projectStatusChannel(projectId)
1910
- try { await announceTaskCompletion({ projectId, ticketId, channelId: activityChannel }) }
1968
+ try { await announceTaskCompletion({ projectId, ticketId }) }
1911
1969
  catch (e) { log('completion report failed for ticket #' + ticketId + ': ' + (e?.message || e) + '; retained for reconnect retry') }
1912
1970
  }
1913
1971
  blockedTasks.delete(key); blockedTaskRepos.delete(key); failedTaskVersions.delete(key); failedOpenCodeProviders.delete(key); persistReplay(); seenTasks.delete(key)
@@ -1936,7 +1994,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1936
1994
  if (activityChannel != null) sendStatus(activityChannel, 'thinking')
1937
1995
  const humanTicketRef = ticketSlug ? `ticket ${ticketSlug}` : `the ticket “${title}”`
1938
1996
  const workspaceDirective = preparedWorktree ? ` A prepared local git worktree for this ticket exists at ${JSON.stringify(preparedWorktree)}. Start repository inspection and edits there. Do not call list_codebases, codebase_tree, or get_codebase while this local worktree is available.` : ''
1939
- void drain(cycleKind, `Authoritative get_ticket verification confirms ${humanTicketRef} is open and assigned to YOU. Internal tool identity: project_id ${projectId}, ticket_id ${ticketId}. For every get_ticket call pass exactly { project_id: ${projectId}, ticket_id: ${ticketId} }; For list_task_types, follow its advertised schema: resolve the ticket’s board_id from the ticket or list_boards and include it when required. Never guess a board id. Numeric ids are MCP arguments only and must never appear in human-facing text; use ${ticketSlug || 'the ticket title'} instead. Ticket details: ${JSON.stringify({ slug: ticketSlug, title, description: ticket.description, priority: ticket.priority, typeId: ticket.type_id ?? ticket.typeId })}.${workspaceDirective} This assignment has no source thread: do not call post_message yourself. Choose the tools, context, validation, and ticket state appropriate to this request. Return your final response when you decide this turn is finished; the watcher will publish it once in the dedicated agent channel without changing ticket status.`, activityChannel == null ? [] : [activityChannel], { projectId, ticketId, channelId: activityChannel })
1997
+ void drain(cycleKind, `Authoritative get_ticket verification confirms ${humanTicketRef} is open and assigned to YOU. Internal tool identity: project_id ${projectId}, ticket_id ${ticketId}. For every get_ticket call pass exactly { project_id: ${projectId}, ticket_id: ${ticketId} }; For list_task_types, follow its advertised schema: resolve the ticket’s board_id from the ticket or list_boards and include it when required. Never guess a board id. Numeric ids are MCP arguments only and must never appear in human-facing text; use ${ticketSlug || 'the ticket title'} instead. Ticket details: ${JSON.stringify({ slug: ticketSlug, title, description: ticket.description, priority: ticket.priority, typeId: ticket.type_id ?? ticket.typeId })}.${workspaceDirective} This assignment has no source thread: do not call post_message yourself. Choose the tools, context, validation, and ticket state appropriate to this request. Return your final response when you decide this turn is finished; the watcher will ask you to select the best-fitting existing channel and publish it there without changing ticket status. A dedicated agent channel is not required.`, activityChannel == null ? [] : [activityChannel], { projectId, ticketId, channelId: activityChannel })
1940
1998
  } catch (e) {
1941
1999
  log(kind + ' ticket verification failed for #' + ticketId + ': ' + (e?.message || e))
1942
2000
  }
@@ -1968,11 +2026,6 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1968
2026
  if (messageSenderIsSelf(comment, { id: selfAgentId, identifier, slug, name })) return { status: 'ignored-self' }
1969
2027
  const peer = messageSenderIsAgent(comment)
1970
2028
  const loopKey = peerTurnKey('task-comment', projectId, ticketId, rootCommentId)
1971
- if (peer && memory.has(loopKey, 'replied')) {
1972
- log(`task:comment project=${projectId} ticket=${ticketId} ignored after one peer-agent reply; waiting for a human turn`)
1973
- return { status: 'ignored-peer-loop' }
1974
- }
1975
- if (!peer) memory.remember({ key: loopKey, kind: 'peer-turn', state: 'human', summary: text, refs: { projectId: Number(projectId), ticketId: Number(ticketId), threadId: Number(rootCommentId) } })
1976
2029
  const target = classifyConversationTarget(comment, [...selfAliases], { threadOwned: true })
1977
2030
  if (target.action !== 'handle') {
1978
2031
  log('task:comment ignored before model start (' + target.reason + ')')
@@ -1994,6 +2047,12 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1994
2047
  log(`task:comment ticket #${ticketId} is not assigned to this agent — ignored`)
1995
2048
  return { status: 'ignored-unassigned' }
1996
2049
  }
2050
+ if (peer) {
2051
+ const meta = admitPeerTurn(memory.get(loopKey), text, target)
2052
+ if (!meta) return { status: 'ignored-peer-loop' }
2053
+ memory.remember({ key: loopKey, kind: 'peer-turn', state: 'reserved', meta })
2054
+ } else memory.remember({ key: loopKey, kind: 'peer-turn', state: 'human' })
2055
+ conversationReader.invalidate({ projectId: Number(projectId), ticketId: Number(ticketId), threadId: Number(rootCommentId) })
1997
2056
  const who = senderName(comment)
1998
2057
  const ticketSlug = ticketDisplaySlug(ticket)
1999
2058
  memory.remember({ key: sourceKey, kind: 'task-comment', state: 'received', summary: text, refs: { projectId: Number(projectId), ticketId: Number(ticketId), messageId: Number(commentId), threadId: Number(rootCommentId) } })
@@ -2006,6 +2065,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
2006
2065
  sourceKey,
2007
2066
  watcherOwned: true,
2008
2067
  peerLoopKey: peer ? loopKey : '',
2068
+ sourceMessage: { ...comment, id: Number(commentId) },
2009
2069
  }
2010
2070
  const label = ticketSlug || String(ticket.title || 'the assigned ticket')
2011
2071
  const context = `The watcher received a new comment on your assigned ticket ${JSON.stringify(label)} from ${JSON.stringify(who || (peer ? 'another agent' : 'a teammate'))}: ${JSON.stringify(text)}. Authoritative ticket details: ${JSON.stringify({ slug: ticketSlug, title: ticket.title, description: ticket.description, priority: ticket.priority, status: ticket.type?.name ?? ticket.task_type?.name ?? ticket.status ?? ticket.state })}. Answer the comment directly in the context of this ticket. Do not call post_message or any task-comment tool; return only the final reply and the watcher will post it once in the source comment thread.${peer ? ' This was authored by another team agent. Address its concrete question or request without replacing it with ticket inventory or adding an open-ended reciprocal question.' : ''}`
@@ -2026,7 +2086,12 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
2026
2086
  const messageCandidate = [outer.message, data.message, payload.message].find((item) => item && typeof item === 'object' && !Array.isArray(item))
2027
2087
  const directId = source.message_id ?? source.messageId ?? source.id
2028
2088
  const directContent = source.content ?? source.body ?? source.text ?? (typeof source.message === 'string' ? source.message : null)
2029
- const message = messageCandidate || (directId != null && directContent != null ? { ...source, content: directContent } : {})
2089
+ let message = messageCandidate || (directId != null && directContent != null ? { ...source, content: directContent } : {})
2090
+ const messageId = message.message_id ?? message.messageId ?? message.id
2091
+ // The documented mention envelope may provide the root outside message.
2092
+ // Explicit inner parent fields always win, including malformed ones.
2093
+ if (!['parent_id', 'parentId', 'thread_id', 'threadId', 'reply_to', 'replyTo'].some((key) => message[key] != null)
2094
+ && validBackendId(source.thread_root_id) && String(source.thread_root_id) !== String(messageId)) message = { ...message, parent_id: Number(source.thread_root_id) }
2030
2095
  const channelId = source.channel_id ?? source.channelId ?? source.channel?.id ?? message.channel_id ?? message.channelId ?? message.channel?.id
2031
2096
  return { ...source, channel_id: channelId, message }
2032
2097
  }
@@ -2034,6 +2099,31 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
2034
2099
  const mentionIntake = new Map()
2035
2100
  function onEvent(k, d) {
2036
2101
  if (stopping) return
2102
+ if (['channel:message:updated', 'channel:message:deleted', 'channel:message:reaction'].includes(k)) {
2103
+ const event = conversationEventData(d)
2104
+ if (validBackendId(event.channel_id)) {
2105
+ conversationReader.clearChannel(Number(event.channel_id))
2106
+ const mid = event.message?.id ?? event.message_id
2107
+ if (k === 'channel:message:deleted' && validBackendId(mid)) {
2108
+ memory.remember({ key: `deleted-message:${Number(event.channel_id)}:${Number(mid)}`, kind: 'message-deletion', state: 'deleted' })
2109
+ contextService.forget(`message:${Number(event.channel_id)}:${Number(mid)}`)
2110
+ contextService.forgetThread?.(Number(event.channel_id), Number(mid))
2111
+ cancelThread(Number(event.channel_id), Number(mid), 'Source message deleted')
2112
+ // A reply can itself be the accepted request. Cancel just its work,
2113
+ // without standing down unrelated requests in the parent thread.
2114
+ const fromDeletedSource = (delivery) => Number(delivery?.channelId) === Number(event.channel_id) && Number(delivery?.sourceMessageId) === Number(mid)
2115
+ for (const [key, delivery] of pendingReplyDeliveries) if (fromDeletedSource(delivery)) pendingReplyDeliveries.delete(key)
2116
+ for (const queue of Object.values(queues)) queue.cancel((item) => fromDeletedSource(item.delivery), (item) => {
2117
+ item.control.cancelled = true; item.control.wakeRecovery?.()
2118
+ void item.control.runner?.cancelCurrent?.('source-deleted')
2119
+ })
2120
+ persistReplay()
2121
+ } else if (k === 'channel:message:updated') {
2122
+ try { contextService.observe({ name: 'list_message_thread', args: { channel_id: Number(event.channel_id) }, result: event.message }) } catch { /* live read remains authoritative */ }
2123
+ }
2124
+ }
2125
+ return
2126
+ }
2037
2127
  if (k === 'task:comment') return trackOperation(handleTaskComment(d))
2038
2128
  const conversationEvent = k === 'agent:mention' || k === 'agent:message' || k === 'channel:message'
2039
2129
  if (!conversationEvent) return trackOperation(routeEvent(k, d))
@@ -2044,6 +2134,8 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
2044
2134
  const messageId = message.message_id ?? message.messageId ?? message.id
2045
2135
  const malformedParent = parent == null && [message.parent_id, message.parentId, message.thread_id, message.threadId, message.reply_to, message.replyTo].some((value) => value != null)
2046
2136
  if (malformedParent || !validBackendId(cid) || !validBackendId(messageId) || (parent != null && !validBackendId(parent))) { log('mention missing valid channel/message identity; ignored'); return }
2137
+ if (memory.has(`deleted-message:${Number(cid)}:${Number(messageId)}`, 'deleted') || parent != null && memory.has(`deleted-message:${Number(cid)}:${Number(parent)}`, 'deleted')) return
2138
+ conversationReader.clearChannel(Number(cid))
2047
2139
  const key = `${cid}:${parent ?? message.message_id ?? message.messageId ?? message.id}`
2048
2140
  // Keep redirects ordered while a legacy root is being verified. Other
2049
2141
  // conversations can resolve and run independently.
@@ -2063,9 +2155,10 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
2063
2155
  // Organization fanout includes our own posts. Ignore stable self identity
2064
2156
  // before recipient checks, replay guards or thread cancellation run.
2065
2157
  if (messageSenderIsSelf(message, { id: selfAgentId, identifier })) return { status: 'ignored-self' }
2066
- const preliminary = classifyConversationTarget(message, [...selfAliases])
2158
+ const preliminary = classifyConversationTarget(message, [...selfAliases], { threadOwned: parent != null && memory.has(threadControlKey(cid, parent), 'active') })
2067
2159
  const irrelevant = preliminary.action === 'ignore' && preliminary.reason !== 'unaddressed-thread-activity'
2068
2160
  let rootContent = ''
2161
+ let contextUnavailable = false
2069
2162
  if (parent != null && !irrelevant && preliminary.action !== 'stand_down') {
2070
2163
  const optional = preliminary.explicitSelf || memory.has(threadControlKey(cid, parent), 'active')
2071
2164
  let contextTimer
@@ -2076,20 +2169,25 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
2076
2169
  // before a model starts. Delivery still re-reads the authoritative thread.
2077
2170
  rootContent = optional ? await Promise.race([context, new Promise((resolve) => {
2078
2171
  contextTimer = setTimeout(() => {
2172
+ contextUnavailable = true
2079
2173
  log(`mention optional context pending channel=${cid} message=${messageId} — continuing with verified recipient`)
2080
2174
  resolve('')
2081
2175
  }, 1000)
2082
2176
  })]) : await context
2083
2177
  }
2084
2178
  catch (error) {
2179
+ contextUnavailable = true
2085
2180
  if (!preliminary.explicitSelf && !memory.has(threadControlKey(cid, parent), 'active')) throw error
2086
2181
  log('optional root context unavailable; preserving verified recipient: ' + (error?.message || error))
2087
2182
  }
2088
2183
  finally { clearTimeout(contextTimer) }
2089
2184
  }
2185
+ // Deletion may arrive during identity/history I/O, before there is a
2186
+ // queue entry for cancellation to find. Recheck at the admission boundary.
2187
+ if (memory.has(`deleted-message:${Number(cid)}:${Number(messageId)}`, 'deleted') || parent != null && memory.has(`deleted-message:${Number(cid)}:${Number(parent)}`, 'deleted')) return { status: 'ignored-deleted' }
2090
2188
  if (!stopping) {
2091
2189
  if (!irrelevant) log(`mention timing intake=${Math.round(performance.now() - intakeStartedAt)}ms source=${k} channel=${cid} message=${messageId}`)
2092
- return routeEvent('agent:mention', { ...eventData, _sourceEvent: k, _threadRootContent: rootContent })
2190
+ return routeEvent('agent:mention', { ...eventData, _sourceEvent: k, _threadRootContent: rootContent, _contextUnavailable: contextUnavailable })
2093
2191
  }
2094
2192
  }).catch((error) => { log('mention thread verification failed: ' + (error?.message || error)); return { status: 'retry' } })
2095
2193
  .finally(() => { if (mentionIntake.get(key) === next) mentionIntake.delete(key) })
@@ -2129,11 +2227,6 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
2129
2227
  log('agent:mention replay recovered for active owned thread')
2130
2228
  }
2131
2229
  const loopKey = cid != null && threadRoot != null ? peerTurnKey('channel', cid, threadRoot) : ''
2132
- if (peerMessage && loopKey && memory.has(loopKey, 'replied')) {
2133
- log('agent:mention ignored before model start (peer-loop-budget)')
2134
- return { status: 'ignored-peer-loop' }
2135
- }
2136
- if (!peerMessage && loopKey) memory.remember({ key: loopKey, kind: 'peer-turn', state: 'human', summary: text, refs: { channelId: Number(cid), threadId: Number(threadRoot) } })
2137
2230
  const target = classifyConversationTarget(msg, [...selfAliases], { threadOwned })
2138
2231
  if (target.action === 'ignore') {
2139
2232
  log('agent:mention ignored before model start (' + target.reason + ')')
@@ -2154,6 +2247,11 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
2154
2247
  }
2155
2248
  return
2156
2249
  }
2250
+ if (peerMessage && loopKey) {
2251
+ const meta = admitPeerTurn(memory.get(loopKey), text, target)
2252
+ if (!meta) { log('agent:mention ignored before model start (peer-loop-budget)'); return { status: 'ignored-peer-loop' } }
2253
+ memory.remember({ key: loopKey, kind: 'peer-turn', state: 'reserved', meta })
2254
+ } else if (loopKey) memory.remember({ key: loopKey, kind: 'peer-turn', state: 'human', summary: text, refs: { channelId: Number(cid), threadId: Number(threadRoot) } })
2157
2255
  if (cid != null && threadRoot != null) activateThread(cid, threadRoot, text)
2158
2256
  memory.remember({ key: sourceKey, kind: 'mention', state: 'received', summary: text, refs: { channelId: cid, threadId: threadRoot, messageId: mid } })
2159
2257
  try { contextService.observe({ name: 'list_message_thread', args: { channel_id: cid, message_id: threadRoot }, result: { ...msg, id: mid, content: String(msg.content || msg.body || msg.text || '') } }) }
@@ -2162,10 +2260,11 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
2162
2260
  // redirect can cancel its process. The watcher delivers the final answer
2163
2261
  // for every runtime after an authoritative last-moment thread check.
2164
2262
  const conversationDelivery = (stage = 'reply', skipIfAnyAgentReply = stage !== 'result', allowChannelMessaging = false) => cid != null
2165
- ? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey, sourceMessageId: mid, watcherOwned: true, allowChannelMessaging, peerLoopKey: peerMessage ? loopKey : '' }
2263
+ ? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey, sourceMessageId: mid, sourceMessage: msg, contextUnavailable: raw._contextUnavailable, watcherOwned: true, allowChannelMessaging, peerLoopKey: peerMessage ? loopKey : '' }
2166
2264
  : null
2167
2265
  // Under-the-hood model control from chat (view / switch the model the agent runs).
2168
2266
  const mcmd = cid != null ? parseModelCmd(text) : null
2267
+ if (peerMessage && mcmd && !mcmd.report) { log('Peer agent cannot change local model configuration'); return }
2169
2268
  if (mcmd) {
2170
2269
  refreshModelSettings()
2171
2270
  const thread = threadRoot != null ? `, parent_id ${threadRoot}` : ''
package/studio/app.mjs CHANGED
@@ -15,7 +15,7 @@ export function normalizeStatus(value, fallback = 'observed') {
15
15
  if (['active', 'running', 'started', 'ready', 'in_progress', 'working', 'online', 'preflight'].includes(status)) return 'active';
16
16
  if (['queued', 'pending', 'waiting'].includes(status)) return 'queued';
17
17
  if (['completed', 'complete', 'finished', 'done', 'success', 'succeeded', 'ok'].includes(status)) return 'completed';
18
- if (['failed', 'failure', 'error', 'rejected', 'rate_limited'].includes(status)) return 'failed';
18
+ if (['failed', 'failure', 'error', 'rejected', 'rate_limited', 'handoff_error'].includes(status)) return 'failed';
19
19
  if (['cancelled', 'canceled', 'aborted', 'interrupted'].includes(status)) return 'cancelled';
20
20
  if (['offline', 'stopped', 'disconnected', 'exited'].includes(status)) return 'offline';
21
21
  if (['skipped', 'recovering', 'idle', 'blocked', 'timeout', 'continued'].includes(status)) return status;
@@ -115,7 +115,7 @@ export function deriveModel(input) {
115
115
  const prior = runtimes.get(processKey) || {};
116
116
  runtimes.set(processKey, {
117
117
  ...prior, ...data, id: processKey, type: 'process', agent: event.agent, event,
118
- name: text(data.runtime || prior.name || data.provider || 'Coding runtime'),
118
+ name: runtimeLabel({ ...prior, ...data }),
119
119
  pid: Number.isInteger(data.runtimePid) && data.runtimePid > 0 ? data.runtimePid : prior.pid ?? null,
120
120
  status: eventStatus(event), lastSeen: event.timestamp,
121
121
  });
@@ -181,15 +181,22 @@ export function taskActivityCycles(model, selectedCycle) {
181
181
  return [...latest.values()].sort((a, b) => agentId(a.agent).localeCompare(agentId(b.agent)));
182
182
  }
183
183
 
184
+ export function runtimeLabel(data = {}) {
185
+ return data.lane === 'reply' ? 'Reply runtime' : data.lane === 'work' ? 'Coding runtime' : 'Agent runtime';
186
+ }
187
+
184
188
  export function eventTitle(event) {
185
189
  const data = record(event?.data);
190
+ if (['process.started', 'process.ready', 'process.stopped'].includes(event?.type)) {
191
+ const phase = event.type === 'process.started' ? 'starting' : event.type === 'process.stopped' ? 'stopped' : data.status === 'idle' ? 'idle' : 'ready';
192
+ return `${runtimeLabel(data)} ${phase}`;
193
+ }
186
194
  const titles = {
187
195
  'runtime.started': 'Watcher connected', 'runtime.heartbeat': 'Watcher heartbeat', 'runtime.stopped': 'Watcher stopped',
188
196
  'cycle.queued': 'Cycle queued', 'cycle.started': 'Cycle started', 'cycle.finished': 'Cycle finished',
189
- 'cycle.continued': 'Continued in coding workspace',
197
+ 'cycle.continued': 'Coding continuation queued',
190
198
  'cycle.completed': 'Cycle completed', 'cycle.cancelled': 'Cycle cancelled', 'cycle.failed': 'Cycle failed',
191
199
  'plan.updated': 'Plan updated', 'output.progress': 'Progress update', 'output.final': 'Final response',
192
- 'process.started': 'Coding runtime starting', 'process.ready': 'Coding runtime ready', 'process.stopped': 'Coding runtime stopped',
193
200
  };
194
201
  if (text(event?.type).startsWith('tool.')) {
195
202
  const action = event.type === 'tool.started' ? 'Started' : ['tool.finished', 'tool.completed'].includes(event.type) ? 'Finished' : 'Updated';
@@ -767,7 +774,7 @@ function initializeStudio() {
767
774
  inspector.replaceChildren(inspectorHeader('Process details', process.name, agentName(process.agent), process.status));
768
775
  const section = detailSection('Process record', 'terminal');
769
776
  section.append(detailGrid([
770
- ['Kind', process.type === 'watcher' ? 'BYO watcher' : 'Coding runtime'], ['Provider', process.agent.provider || process.provider],
777
+ ['Kind', process.type === 'watcher' ? 'BYO watcher' : runtimeLabel(process)], ['Provider', process.agent.provider || process.provider],
771
778
  ['PID', process.pid || (process.status === 'demo' ? 'Simulated' : 'Not reported'), true],
772
779
  ['Last event', fullTime(process.lastSeen)], ['Session', process.sessionId, true], ['Run ID', process.runId || process.event?.runId, true],
773
780
  ]));
@@ -802,6 +809,7 @@ function initializeStudio() {
802
809
  return;
803
810
  }
804
811
  $('demo-badge').hidden = !state.snapshot.demo;
812
+ $('open-agent-connect').disabled = state.snapshot.demo;
805
813
  for (const button of document.querySelectorAll('[data-view]')) {
806
814
  const selected = button.dataset.view === state.view; button.classList.toggle('is-selected', selected); button.setAttribute('aria-pressed', String(selected));
807
815
  }
@@ -813,6 +821,55 @@ function initializeStudio() {
813
821
  }
814
822
 
815
823
  $('server-address').textContent = location.host;
824
+ let connectingAgent = false;
825
+ const credentialFields = ['name', 'identifier', 'apiKey', 'backend', 'mcpUrl', 'wsUrl', 'agent'];
826
+ $('open-agent-connect').addEventListener('click', () => {
827
+ $('agent-connect-error').hidden = true;
828
+ $('agent-connect-dialog').showModal();
829
+ });
830
+ $('close-agent-connect').addEventListener('click', () => { if (!connectingAgent) $('agent-connect-dialog').close(); });
831
+ $('agent-connect-dialog').addEventListener('cancel', event => { if (connectingAgent) event.preventDefault(); });
832
+ $('agent-connect-dialog').addEventListener('close', () => $('agent-connect-form').reset());
833
+ $('connect-credentials').addEventListener('input', () => {
834
+ if (!$('connect-credentials').value.trim()) return;
835
+ try {
836
+ const credentials = JSON.parse($('connect-credentials').value);
837
+ if (!credentials || typeof credentials !== 'object' || Array.isArray(credentials)) throw new Error();
838
+ for (const key of credentialFields) $('connect-' + key).value = typeof credentials[key] === 'string' ? credentials[key] : key === 'agent' ? 'claude' : '';
839
+ $('connect-credentials').value = '';
840
+ $('agent-connect-error').hidden = true;
841
+ } catch {
842
+ $('agent-connect-error').textContent = 'Paste the complete credentials from Copy credentials in OpenVisio, or enter the fields below.';
843
+ $('agent-connect-error').hidden = false;
844
+ }
845
+ });
846
+ $('agent-connect-form').addEventListener('submit', async event => {
847
+ event.preventDefault();
848
+ if (connectingAgent) return;
849
+ connectingAgent = true;
850
+ $('save-agent-connect').disabled = true;
851
+ $('close-agent-connect').disabled = true;
852
+ $('save-agent-connect').textContent = 'Verifying…';
853
+ $('agent-connect-error').hidden = true;
854
+ try {
855
+ const input = Object.fromEntries(credentialFields.map(key => [key, $('connect-' + key).value.trim()]));
856
+ const result = await settingsFetch('/api/agents/connect', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(input) });
857
+ $('agent-connect-dialog').close();
858
+ state.selectedAgent = result.identifier;
859
+ state.agentSearch = ''; $('agent-search').value = '';
860
+ toast('Agent connected. Choose Start watcher when your runtime is installed and signed in.');
861
+ receive(await settingsFetch('/api/snapshot'), true);
862
+ } catch (error) {
863
+ $('agent-connect-error').hidden = false;
864
+ $('agent-connect-error').textContent = error.message;
865
+ } finally {
866
+ connectingAgent = false;
867
+ $('save-agent-connect').disabled = false;
868
+ $('close-agent-connect').disabled = false;
869
+ $('save-agent-connect').textContent = 'Verify and connect';
870
+ }
871
+ });
872
+
816
873
  $('open-model-settings').addEventListener('click', () => {
817
874
  const agent = state.model.agents.find(agent => agentId(agent) === state.selectedAgent);
818
875
  if (agent?.settingsEditable) void openModelSettings(agent);
package/studio/guide.html CHANGED
@@ -58,7 +58,7 @@
58
58
  <h2>Show your agents</h2>
59
59
  <p>A <strong>watcher</strong> is the local program that listens for assignments and messages, then starts your coding agent when work arrives. Studio displays what the watcher records.</p>
60
60
  <ol>
61
- <li>Connect your agent using the setup command provided by your OpenVisio team. If you already connected it, keep that setup.</li>
61
+ <li>After registering your agent in OpenVisio, choose <strong>Copy credentials</strong>. In Studio, choose <strong>Connect agent</strong>, paste the credentials, and choose <strong>Verify and connect</strong>. You can also enter the fields individually or use the optional terminal command. If you already connected it, keep that setup.</li>
62
62
  <li>Select the agent in Studio and choose <strong>Start watcher</strong>. Studio installs its background watcher and starts it with the current package and saved model settings.</li>
63
63
  <li>Return to Studio. Your agent should appear in the sidebar. Select its name to see its activity.</li>
64
64
  </ol>
package/studio/index.html CHANGED
@@ -60,13 +60,31 @@
60
60
  <main id="workspace" class="workspace" tabindex="-1">
61
61
  <header class="page-header">
62
62
  <div><div class="eyebrow">Runtime overview <span id="demo-badge" class="badge demo-badge" hidden>Demo data</span></div><h1>Agent studio</h1><p class="page-description">See what your agents are doing, as it happens.</p></div>
63
- <div class="header-actions"><a class="button header-documentation" href="/guide"><svg class="icon" aria-hidden="true"><use href="#i-book"/></svg>Documentation</a><span id="connection-status" class="connection-status" role="status"><span class="dot"></span><span>Connecting</span></span><button id="pause-button" class="button" type="button" aria-pressed="false"><svg class="icon" aria-hidden="true"><use href="#i-pause"/></svg><span>Pause view</span></button></div>
63
+ <div class="header-actions"><button id="open-agent-connect" class="button primary" type="button" disabled>Connect agent</button><a class="button header-documentation" href="/guide"><svg class="icon" aria-hidden="true"><use href="#i-book"/></svg>Documentation</a><span id="connection-status" class="connection-status" role="status"><span class="dot"></span><span>Connecting</span></span><button id="pause-button" class="button" type="button" aria-pressed="false"><svg class="icon" aria-hidden="true"><use href="#i-pause"/></svg><span>Pause view</span></button></div>
64
64
  </header>
65
65
  <div id="notice" class="notice" role="status" hidden></div>
66
66
  <section id="model-settings-summary" class="model-settings-summary" aria-label="Agent model settings" hidden>
67
67
  <div><h2 id="model-settings-agent">Model settings</h2><p id="model-settings-state" role="status"></p></div>
68
68
  <div class="header-actions model-settings-actions-row"><button id="toggle-watcher" class="button studio-action" type="button"><svg class="icon" aria-hidden="true"><use href="#i-power"/></svg><span>Start watcher</span></button><button id="restart-watcher" class="button studio-action restart-action" type="button"><svg class="icon" aria-hidden="true"><use href="#i-refresh"/></svg><span>Restart watcher</span></button><button id="open-model-settings" class="button studio-action settings-action" type="button"><svg class="icon" aria-hidden="true"><use href="#i-sliders"/></svg><span>Model settings</span></button><button id="open-agent-removal" class="button studio-action remove-action" type="button"><svg class="icon" aria-hidden="true"><use href="#i-user-minus"/></svg><span>Remove agent</span></button></div>
69
69
  </section>
70
+ <dialog id="agent-connect-dialog" aria-labelledby="agent-connect-title">
71
+ <form id="agent-connect-form" class="model-settings-form" autocomplete="off">
72
+ <div class="section-heading"><h2 id="agent-connect-title">Connect agent</h2><button id="close-agent-connect" class="button" type="button">Cancel</button></div>
73
+ <p>Copy credentials from OpenVisio after registering your agent, then paste them below. Or enter the fields yourself.</p>
74
+ <label for="connect-credentials">Paste credentials</label>
75
+ <textarea id="connect-credentials" rows="3" spellcheck="false" placeholder="Paste the copied credentials here"></textarea>
76
+ <label for="connect-name">Local agent name</label><input id="connect-name" type="text" required maxlength="4096" spellcheck="false">
77
+ <label for="connect-identifier">Agent identifier</label><input id="connect-identifier" type="text" required maxlength="4096" spellcheck="false">
78
+ <label for="connect-apiKey">API key</label><input id="connect-apiKey" type="password" required maxlength="4096" spellcheck="false">
79
+ <label for="connect-backend">Backend URL</label><input id="connect-backend" type="url" required maxlength="4096" spellcheck="false">
80
+ <label for="connect-mcpUrl">MCP URL</label><input id="connect-mcpUrl" type="url" required maxlength="4096" spellcheck="false">
81
+ <label for="connect-wsUrl">WebSocket URL</label><input id="connect-wsUrl" type="url" required maxlength="4096" spellcheck="false">
82
+ <label for="connect-agent">Runtime</label><select id="connect-agent"><option value="claude">Claude Code</option><option value="codex">Codex</option><option value="opencode">OpenCode</option><option value="gemini">Gemini CLI</option><option value="qwen">Qwen Code</option></select>
83
+ <p>Install and sign in to your chosen runtime on this computer. After connecting, select the agent and choose Start watcher.</p>
84
+ <p id="agent-connect-error" role="alert" hidden></p>
85
+ <div class="model-settings-actions"><button id="save-agent-connect" class="button primary" type="submit">Verify and connect</button></div>
86
+ </form>
87
+ </dialog>
70
88
  <dialog id="model-settings-dialog" aria-labelledby="model-settings-title">
71
89
  <form id="model-settings-form" class="model-settings-form">
72
90
  <div class="section-heading"><h2 id="model-settings-title">Model settings</h2><button id="close-model-settings" class="button" type="button" aria-label="Close model settings">Close</button></div>