openvisio-agent 0.25.8 → 0.25.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/CHANGELOG.md CHANGED
@@ -1,6 +1,19 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## [0.25.10] — 2026-09-21
4
+
5
+ - Send `thinking`, `working`, and `typing` activity through the authenticated agent WebSocket, with the REST endpoint retained only while the socket is reconnecting.
6
+ - Accept documented and compatible WebSocket event envelopes on both the watcher and frontend, including flat bare-agent `agent_status` frames, and normalize them before routing.
7
+ - Keep active cycles running through ordinary peer-agent chatter; only an analyzed redirect or explicit stand-down cancels work in that thread.
8
+ - Compact Agent Studio activity and cycle rows into single-line summaries so more history remains visible at once.
9
+
10
+ ## [0.25.9] — 2026-09-21
11
+
12
+ - Keep pending-ticket detection within one sentence or clause, preventing a peer's ticket-status statement from hijacking a separate onboarding question.
13
+ - Let BYO agents answer concrete peer questions and requests in an owned thread without requiring every turn to repeat an @mention.
14
+ - Keep routine agent chatter silent and instruct peer replies to finish decisively instead of creating automated reply loops.
15
+ - Route new comments on an assigned task through authoritative ownership checks and post one deduplicated reply in the source comment thread, including concrete requests from another BYO agent.
16
+ - Remove the 30-second activity-feed recovery timer and startup `list_activity` pass. Healthy watchers now remain on WebSocket delivery while idle, with MCP recovery limited to connection and outstanding-delivery events.
4
17
 
5
18
  ## [0.25.8] — 2026-09-21
6
19
 
package/README.md CHANGED
@@ -81,13 +81,13 @@ The integration follows Mastra's [semantic memory](https://mastra.ai/docs/memory
81
81
 
82
82
  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.
83
83
 
84
- Backend/BYO watchers reconcile assignments whenever the WebSocket starts or reconnects. 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 work missed while offline. Because the backend can omit an addressed message from the watcher stream, the watcher retains the recovered project IDs and checks only recent `list_activity` every 30 seconds; it does not repeat project, board, task-type, or task scans while idle. Team discovery is shared by every runtime provider: structured MCP results and compact Markdown list envelopes are normalized once before the watcher resolves its project, identity, boards, tickets, or dedicated channel. Adding another model provider therefore reuses the same saved team connection and setup path instead of adding provider-specific project discovery. Optional actions such as ticket comments are used only when advertised; their absence cannot strand a completed ticket in a retry loop. The activity check starts a model only when it finds a new addressed message.
84
+ Backend/BYO watchers reconcile assignments whenever the WebSocket starts or reconnects. 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`, and `list_task_types` to recover assigned work after a connection event. The authenticated profile lookup also supplies the verified organization needed for channel, assignment, and task-comment subscriptions. Once connected, the watcher makes no periodic `list_activity`, roster, project, board, task-type, or task calls while idle; incoming WebSocket events start the relevant verification path. Team discovery is shared by every runtime provider: structured MCP results and compact Markdown list envelopes are normalized once before the watcher resolves its project, identity, boards, tickets, or dedicated channel. Adding another model provider therefore reuses the same saved team connection and setup path instead of adding provider-specific project discovery. New comments on a task assigned to the agent are verified with `get_ticket`; human comments and concrete peer-agent requests receive one deduplicated reply in the source comment thread. Optional comment actions are used only when advertised, and their absence cannot strand a completed ticket in a retry loop.
85
85
 
86
86
  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.
87
87
 
88
88
  Codex BYO agents follow the repository's normative runtime specification in `docs/CODEX_BYO_AGENT_SPEC.md`: one WebSocket identity, independent reply/work queues, authoritative `get_ticket` verification for assignments, silent transient activity, persistent replay suppression, and agent-controlled turn completion. Maintainers must run `npm run certify` before publishing.
89
89
 
90
- Both `agent:mention` and organization `channel:message` events pass through the same source-recipient, ownership, and deduplication checks before any model starts. The watcher hydrates compact roster entries through `get_agent`, subscribes only to the organization verified from that authenticated profile, and restores the subscription after reconnect. Organization `task:created` and `task:updated` events enter the same authoritative assignment verifier as targeted `task:assigned` events, so a task starts only after `get_ticket` confirms it is open and owned by this agent. Self-authored source messages are ignored; embedded old agent replies cannot hide a human follow-up. 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. Concrete roots verified as belonging to someone else have a bounded one-minute cache; missing authors and failed reads remain immediately retryable. The watcher publishes working, thinking, and typing presence from actual runtime activity using the authenticated REST endpoint. It stops refreshing when work finishes or is cancelled; the app clears the indicator through its expiry timer. Accepted coding tasks publish a short native plan first; the watcher sends one acknowledgement with up to three steps to the source thread or default agent channel. Every runtime returns a distinct final answer for guarded watcher delivery. Plans and reconnects cannot duplicate or suppress that final answer.
90
+ Both `agent:mention` and organization `channel:message` events pass through the same source-recipient, ownership, and deduplication checks before any model starts. The watcher hydrates compact roster entries through `get_agent`, subscribes only to the organization verified from that authenticated profile, and restores the subscription after reconnect. Organization `task:created` and `task:updated` events enter the same authoritative assignment verifier as targeted `task:assigned` events, so a task starts only after `get_ticket` confirms it is open and owned by this agent. Self-authored source messages are ignored; embedded old agent replies cannot hide a human follow-up. An explicit tag establishes durable ownership of that thread, so later human follow-ups remain addressed to the agent without another @mention. Another BYO agent can continue that owned thread with a concrete question or request without repeating the mention; routine agent status chatter remains silent to prevent reply loops. Ticket-inventory intent is sentence-scoped, so a peer saying it has no assigned tickets cannot hijack a separate onboarding or coordination question. Messages redirected to another agent stay silent, while a direct stand-down cancels queued/running work and releases ownership for that thread. Concrete roots verified as belonging to someone else have a bounded one-minute cache; missing authors and failed reads remain immediately retryable. The watcher publishes working, thinking, and typing presence from actual runtime activity using the authenticated REST endpoint. It stops refreshing when work finishes or is cancelled; the app clears the indicator through its expiry timer. Accepted coding tasks publish a short native plan first; the watcher sends one acknowledgement with up to three steps to the source thread or default agent channel. Every runtime returns a distinct final answer for guarded watcher delivery. Plans and reconnects cannot duplicate or suppress that final answer.
91
91
 
92
92
  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.
93
93
 
package/USER_GUIDE.md CHANGED
@@ -76,6 +76,8 @@ Stopping a watcher interrupts its active work. When you are ready to resume, run
76
76
 
77
77
  Replies stay in their source thread. New completion messages use the agent’s dedicated channel by default. You can explicitly ask the agent to send or announce something in another named channel; it resolves that destination from the live channel list and confirms the action in the source thread. Check the ticket and any linked pull request for delivered results. A progress message describes ongoing work; wait for the final result or blocker.
78
78
 
79
+ BYO agents can coordinate directly in a shared thread. Mention an agent once to establish the conversation; concrete peer questions and requests can continue there without repeating the mention. Routine agent status messages do not trigger replies, which prevents automated conversations from looping.
80
+
79
81
  For accepted coding tasks, the agent starts by publishing a short plan. You receive one acknowledgement with up to three steps, followed later by its result. The agent can delegate independent pieces to native sub-agents and remains responsible for checking their work. Thinking, working, and typing indicators follow runtime activity; they expire after work stops. A reconnect does not repeat the plan or cancel healthy work.
80
82
 
81
83
  When a conversation turns into coding work, the agent includes its acknowledgement and two or three next steps in the workspace handoff. They are posted to the original channel thread before coding starts. Private continuation context stays internal, and the work session does not send a second pickup message.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.25.8",
3
+ "version": "0.25.10",
4
4
  "description": "Connect Claude Code, Codex, OpenCode, Gemini CLI, or Qwen Code to an OpenVisio team \u2014 MCP tools + optional autonomy \u2014 in one command.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -39,7 +39,7 @@ const recipientCases = [
39
39
  ['shared-request', '@Alex and @Atlas please review this.', 'handle'],
40
40
  ['explicit-shared-request', '@Alex and @Atlas can you both fix this?', 'handle'],
41
41
  ['delegation-through-self', '@Alex ask @Atlas to review this.', 'handle'],
42
- ['mention-in-email', 'Please send the update to contact@alex.', 'follow-up'],
42
+ ['mention-in-email', 'Please send the update to contact@alex.', 'peer-follow-up'],
43
43
  ['email-to-other-person', 'Please use daniel@atlas.example for contact.', 'follow-up'],
44
44
  ['longer-handle', '@Alexandra please review.', 'ignore'],
45
45
  ['dotted-different-handle', '@Alex.morgan please review.', 'ignore'],
@@ -50,6 +50,7 @@ for (const [caseId, content, policy] of recipientCases) {
50
50
  for (const [senderId, sender, agent] of senders) {
51
51
  for (const owned of [false, true]) {
52
52
  const expected = policy === 'follow-up' ? (!agent && owned ? 'handle' : 'ignore')
53
+ : policy === 'peer-follow-up' ? (owned ? 'handle' : 'ignore')
53
54
  : policy === 'stop-follow-up' ? (!agent && owned ? 'stand_down' : 'ignore') : policy
54
55
  add(`recipient-${caseId}-${senderId}-${owned ? 'owned' : 'unowned'}`, 'recipient', `${caseId}: ${senderId} in ${owned ? 'an owned' : 'an unowned'} thread must ${expected}.`, () => {
55
56
  assert.equal(classifyConversationTarget({ id: 101, parent_id: 100, content, ...sender }, aliases, { threadOwned: owned }).action, expected)
@@ -219,6 +220,11 @@ add('completed-ticket-status-does-not-reopen', 'assignment-intent', 'A status qu
219
220
  await routeAssignments({ request: { slugs: ['OPEN-77'] }, loadTickets: async () => [{ id: 77, projectId: 1, slug: 'OPEN-77', status: 'Done' }], handleTask: async () => { dispatched++ } })
220
221
  assert.equal(dispatched, 0)
221
222
  })
223
+ add('peer-onboarding-does-not-become-ticket-inventory', 'assignment-intent', 'A peer onboarding statement and later coordination question are not combined into a pending-ticket request.', () => {
224
+ const text = 'I currently have zero assigned tickets. Which repos are mine, and where do routine updates get posted?'
225
+ assert.equal(conversationAsksPendingTickets(text), false)
226
+ assert.equal(classifyConversationTarget({ content: text, senderAgent: { id: 9 } }, aliases, { threadOwned: true }).reason, 'owned-peer-follow-up')
227
+ })
222
228
 
223
229
  const ticketStates = [
224
230
  ['actionable', { status: 'In Progress' }, true],
@@ -91,6 +91,10 @@ export function fixture({ agent = 'codex', name, task = true, run, threadError =
91
91
  messages.push(message)
92
92
  await watcher.receive('agent:mention', { channel_id: channel, message })
93
93
  },
94
+ async taskComment(content, { id = 300, parent, sender = human, projectId = 1, taskId = 77 } = {}) {
95
+ const comment = { id, task_id: taskId, content, taskSender: sender, ...(parent != null ? { parent_id: parent } : {}) }
96
+ await watcher.receive('task:comment', { project_id: projectId, task_id: taskId, comment })
97
+ },
94
98
  async cleanup() { await watcher.close(); rmSync(dir, { recursive: true, force: true }) },
95
99
  }
96
100
  }
@@ -18,6 +18,7 @@ for (const directory of ['src', 'bin', 'studio']) {
18
18
  }
19
19
  run('unit and behavior tests', process.execPath, ['--test'])
20
20
  run('frontend typecheck', 'npm', ['run', 'typecheck'], join(repo, 'frontend'))
21
+ run('frontend realtime frame tests', process.execPath, ['--test', 'lib/api/backend/realtimeFrame.test.mjs'], join(repo, 'frontend'))
21
22
  run('package dry run', 'npm', ['pack', '--dry-run'])
22
23
  run('diff whitespace check', 'git', ['diff', '--check'], repo)
23
24
 
@@ -32,6 +33,7 @@ const codexProxy = readFileSync(join(root, 'src', 'codex-mcp-proxy.mjs'), 'utf8'
32
33
  const prPush = readFileSync(join(root, 'src', 'pr-push.mjs'), 'utf8')
33
34
  const cli = readFileSync(join(root, 'bin', 'cli.mjs'), 'utf8')
34
35
  const websocket = readFileSync(join(root, 'src', 'ws.mjs'), 'utf8')
36
+ const activityReporter = readFileSync(join(root, 'src', 'activity-reporter.mjs'), 'utf8')
35
37
  const cycleQueue = readFileSync(join(root, 'src', 'cycle-queue.mjs'), 'utf8')
36
38
  const mastraHarness = readFileSync(join(root, 'src', 'mastra-harness.mjs'), 'utf8')
37
39
  const runtimeProviders = readFileSync(join(root, 'src', 'runtime-providers.mjs'), 'utf8')
@@ -52,9 +54,9 @@ const assertions = [
52
54
  ['new follow-ups only deduplicate later replies', watcher.includes('repliesAfterSource(renderedAgentMessages') && watcher.includes('sourceMessageId: mid')],
53
55
  ['Mastra snapshots do not block the event loop', mastraHarness.includes('await execFileAsync') && !mastraHarness.includes('spawnSync')],
54
56
  ['OpenCode exits after work while the event watcher stays idle', runtimeProviders.includes("id: 'opencode'") && runtimeProviders.includes('idleSessionMs: 0') && watcher.includes('idleMs: runtimeIdleMs')],
55
- ['healthy backend watchers recover missed messages without polling projects or tasks while idle', watcher.includes('onConnect: () =>') && watcher.includes('void reconcileBacklog()') && watcher.includes('activityRecoveryTimer') && watcher.includes('replayRecentActivity(knownProjects)') && !watcher.includes('taskProbeTimer') && !watcher.includes('taskProbeStartTimer')],
57
+ ['healthy backend watchers do not poll MCP activity while idle', watcher.includes('onConnect: () =>') && watcher.includes('void reconcileBacklog()') && !watcher.includes('activityRecoveryTimer') && !watcher.includes('replayRecentActivity') && !watcher.includes("callMcpTool('list_activity'") && !watcher.includes('taskProbeTimer') && !watcher.includes('taskProbeStartTimer')],
56
58
  ['idle watchers use event notifications without journal or settings polling', watcher.includes('watchFs(stateDir') && !watcher.includes('modelSettingsTimer') && !journal.includes("emit('runtime.heartbeat'")],
57
- ['ticket pickup precedes optional activity reads', watcher.indexOf("await handleTaskSignal('task:assigned'", watcher.indexOf('const reconcileBacklog')) < watcher.indexOf('await replayRecentActivity(projects)', watcher.indexOf('const reconcileBacklog'))],
59
+ ['connection recovery scans assignments without an activity-feed pass', watcher.includes('const reconcileBacklog') && watcher.includes("await handleTaskSignal('task:assigned'") && !watcher.includes('reconcileActivity')],
58
60
  ['task:assigned is handled', watcher.includes("k === 'task:assigned'")],
59
61
  ['task creation and update fanout use the assignment verifier', watcher.includes("k === 'task:created'") && watcher.includes("k === 'task:updated'") && watcher.includes('return handleTaskSignal(k, raw)')],
60
62
  ['compact agent records are hydrated before organization subscription', watcher.includes("callMcpTool('get_agent', { agent_id: selfAgentId })") && watcher.includes('selfAgentId != null && selfOrganizationId == null')],
@@ -65,15 +67,16 @@ const assertions = [
65
67
  ['agent results are delivered independently of ticket completion', watcher.includes('agentFinal: true') && watcher.includes('pendingAgentResults')],
66
68
  ['completion delivery survives reconnect and deduplicates', watcher.includes('pendingCompletionReports: [...pendingCompletionReports]') && watcher.includes('reportedCompletions: [...reportedCompletions]') && watcher.includes('reportedCompletions.has(report.key)')],
67
69
  ['optional ticket comments cannot block verified completion', watcher.includes('callOptionalTicketComment({ projectId, ticketId, content: report.content })') && watcher.includes('no ticket comment tool is exposed; completing ticket') && watcher.includes('reportedTaskComments: [...reportedTaskComments]') && watcher.includes('reportedTaskComments.has(report.key)')],
68
- ['ticket comments prefer the advertised modern schema with legacy compatibility', watcher.includes("names.has('create_task_comment') ? 'create_task_comment' : names.has('comment_ticket') ? 'comment_ticket' : ''") && watcher.includes("name === 'create_task_comment' ? { content } : { text: content }")],
70
+ ['ticket comments prefer the advertised modern schema with legacy compatibility', watcher.includes("names.has('create_task_comment') ? 'create_task_comment' : names.has('comment_ticket') ? 'comment_ticket' : ''") && watcher.includes("name === 'create_task_comment'") && watcher.includes("{ parent_id: Number(parentId) }") && watcher.includes("{ text: content }")],
69
71
  ['uncertain comment writes never trigger duplicate alternate ticket writes', watcher.includes('never try another comment endpoint after an error') && watcher.includes('no alternate ticket write will be attempted') && !watcher.includes("reason: 'not-available'")],
70
72
  ['ticket comments cannot masquerade as channel completion', events.includes("didChannelMessage: completed && mcpTool === 'post_message'") && watcher.includes('result.didChannelMessage')],
71
73
  ['single-watcher acquisition is atomic and fails closed', watcher.includes("openSync(lockPath, 'wx')") && watcher.includes('Could not acquire the single-watcher lock')],
72
- ['websocket and activity mention delivery share a replay guard', watcher.includes('markMentionHandled(activityMessage, activityChannelId)') && watcher.includes('markMentionHandled(msg, cid)') && watcher.includes('recentMentionSignatures')],
73
- ['reconciled mentions reuse the guarded websocket delivery path', watcher.includes("onEvent('agent:mention'") && watcher.includes('_mentionAlreadyMarked: true')],
74
- ['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'")],
74
+ ['websocket mention delivery uses a persistent replay guard', watcher.includes('markMentionHandled(msg, cid)') && watcher.includes('recentMentionSignatures')],
75
+ ['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("'owned-thread-follow-up'") && events.includes("reason: 'explicit-other-recipient'") && events.includes("reason: 'other-agent-chatter'")],
76
+ ['owned threads admit concrete peer requests without opening agent chatter loops', events.includes("reason: peerFollowUp ? 'owned-peer-follow-up'") && watcher.includes('This request was authored by another team agent') && watcher.includes('do not append a reciprocal question')],
77
+ ['pending-ticket intent is scoped to one clause', events.includes("text.split(/(?<=[.!?])") && events.includes('return tasks && pending && question')],
75
78
  ['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')],
76
- ['reconciliation recovers untagged human follow-ups in owned threads', watcher.includes("memory.has(activityControlKey, 'active')") && watcher.includes('const addressedActivity = mentionNeedles.some') && watcher.includes("memory.has(activitySourceKey, 'received')")],
79
+ ['task comments use assigned-ticket verification and watcher-owned threaded delivery', watcher.includes("k === 'task:comment'") && watcher.includes('taskCommentFromEvent(raw)') && watcher.includes('postTaskCommentOnce') && watcher.includes("surface: 'task-comment'") && watcher.includes("['post_message', 'create_task_comment', 'comment_ticket']")],
77
80
  ['coding mentions require an action and concrete repository target', watcher.includes('conversationNeedsCode(text, { rootContent:') && events.includes('const action =') && events.includes('const target =')],
78
81
  ['agent-authored roots establish ownership and unaddressed legacy roots are verified before routing', watcher.includes('saveOwnThread(channelId, postedId, message)') && watcher.includes('const context = resolveOwnThread(cid, parent)') && watcher.includes('})]) : await context') && watcher.includes('_threadRootContent: rootContent')],
79
82
  ['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'")],
@@ -81,7 +84,7 @@ const assertions = [
81
84
  ['generic coding pickup messages are absent', !watcher.includes("I've picked this up and will return here with the verified result")],
82
85
  ['human-facing ticket references require slugs, never database ids', watcher.includes('TICKET SLUGS, NEVER DATABASE IDS') && watcher.includes('ticketDisplaySlug(task)') && events.includes('export function ticketDisplaySlug') && !events.includes('I finished ticket #${task.id}')],
83
86
  ['rendered backend replies are checked before every guarded thread post', watcher.includes("callMcpTool('list_message_thread'") && watcher.includes('renderedAgentMessages(live') && watcher.includes('same-content-rendered')],
84
- ['guarded replies cannot race the watcher while explicit channel delivery remains available', watcher.includes("delivery?.watcherOwned && !delivery?.allowChannelMessaging") && watcher.includes('conversationRequestsChannelMessage(text)') && codexConfig.includes('disabled_tools = [')],
87
+ ['guarded replies cannot race the watcher while explicit channel delivery remains available', watcher.includes("delivery?.watcherOwned && !delivery?.allowChannelMessaging") && watcher.includes('conversationRequestsChannelMessage(text)') && watcher.includes("delivery?.surface === 'task-comment'") && codexConfig.includes('disabled_tools = [')],
85
88
  ['ACP sessions receive the authenticated OpenVisio proxy', mastraHarness.includes("name: 'openvisio-team-watcher'") && mastraHarness.includes('OPENVISIO_CODEX_MCP_URL')],
86
89
  ['Codex discovers deferred OpenVisio actions before claiming they are missing', watcher.includes('Codex may defer MCP actions') && watcher.includes('use tool_search to find that exact action') && watcher.includes('initial-list miss is NOT evidence that the server is disconnected')],
87
90
  ['Codex authentication is injected outside the model', codexConfig.includes('OPENVISIO_CODEX_API_KEY') && codexProxy.includes('toolWithoutCredentialInputs') && codexProxy.includes('client.callTool') && watcher.includes('local OpenVisio MCP bridge injects authentication outside the model')],
@@ -99,7 +102,7 @@ const assertions = [
99
102
  ['work and reply cancellation targets are isolated', watcher.includes('item.control.cancelled = true') && watcher.includes('item.control.runner?.cancelCurrent')],
100
103
  ['fresh threads resolve the dedicated agent channel', watcher.includes('dedicatedChannel(Array.isArray(channels)') && watcher.includes('No dedicated agent channel available for a new thread')],
101
104
  ['presence follows runtime activity and stops refreshing with its cycle', watcher.includes('createActivityReporter') && watcher.includes("activity.set(data.cycleId, live.control.statusTargets, 'working')") && watcher.includes('activity.clear(cycleControl.cycleId)')],
102
- ['websocket client cannot emit legacy agent_status', !websocket.includes('agent_status')],
105
+ ['agent activity prefers websocket with REST reconnect fallback', activityReporter.includes("type: 'agent_status'") && activityReporter.includes('sendFrame?.') && activityReporter.includes('agentStateRequest') && websocket.includes('return true')],
103
106
  ['frontend consumes thinking event', activityHook.includes("'channel:agent:thinking'")],
104
107
  ['frontend consumes working event', activityHook.includes("'channel:agent:working'")],
105
108
  ['frontend consumes typing event', activityHook.includes("'channel:agent:typing'")],
@@ -2,11 +2,11 @@ import { agentStateRequest } from './events.mjs'
2
2
 
3
3
  // Presence is ephemeral, independent of task completion and message delivery.
4
4
  // The backend supports no "done" event; stopping refresh lets the UI TTL clear it.
5
- export function createActivityReporter({ backend, apiKey, identifier, fetchImpl = fetch, now = Date.now, log = () => {}, intervalMs = 3_000 }) {
5
+ export function createActivityReporter({ backend, apiKey, identifier, sendFrame, fetchImpl = fetch, now = Date.now, log = () => {}, intervalMs = 3_000 }) {
6
6
  const cycles = new Map(), channels = new Map(), pending = new Set()
7
7
  let closed = false
8
8
  const pulse = () => {
9
- if (closed || !backend) return
9
+ if (closed || (!backend && !sendFrame)) return
10
10
  const states = new Map()
11
11
  const priority = { thinking: 0, working: 1, typing: 2 }
12
12
  for (const cycle of cycles.values()) for (const channelId of cycle.channels) {
@@ -15,11 +15,19 @@ export function createActivityReporter({ backend, apiKey, identifier, fetchImpl
15
15
  for (const [channelId, state] of states) {
16
16
  const prior = channels.get(channelId)
17
17
  if (prior?.busy || now() < (prior?.retryAt || 0) || (prior?.state === state && now() - prior.at < (state === 'working' ? 10_000 : 3_000))) continue
18
+ const entry = { state, at: now(), busy: false, controller: null }
19
+ channels.set(channelId, entry)
20
+ // The authenticated agent socket is the live event path. Older backend
21
+ // stages and reconnect gaps retain the REST endpoint as a fallback.
22
+ try {
23
+ if (sendFrame?.({ type: 'agent_status', channel_id: channelId, state }) === true) continue
24
+ } catch { /* fall through to REST */ }
25
+ if (!backend) continue
18
26
  const controller = new AbortController()
19
27
  const timer = setTimeout(() => controller.abort(), 5_000)
20
28
  timer.unref?.()
21
- const entry = { state, at: now(), busy: true, controller }
22
- channels.set(channelId, entry)
29
+ entry.busy = true
30
+ entry.controller = controller
23
31
  const request = agentStateRequest(backend, channelId, state, apiKey, identifier)
24
32
  const operation = Promise.resolve().then(() => fetchImpl(request.url, { ...request.init, signal: controller.signal })).then(async response => {
25
33
  await response.body?.cancel?.()
@@ -42,6 +50,6 @@ export function createActivityReporter({ backend, apiKey, identifier, fetchImpl
42
50
  clear(cycleId) { cycles.delete(cycleId); pulse() },
43
51
  pulse,
44
52
  async settled() { await Promise.allSettled([...pending]) },
45
- async close() { closed = true; clearInterval(timer); cycles.clear(); for (const entry of channels.values()) entry.controller.abort(); await Promise.allSettled([...pending]) },
53
+ async close() { closed = true; clearInterval(timer); cycles.clear(); for (const entry of channels.values()) entry.controller?.abort(); await Promise.allSettled([...pending]) },
46
54
  }
47
55
  }
package/src/events.mjs CHANGED
@@ -21,6 +21,23 @@ export function taskFromEvent(payload) {
21
21
  return null
22
22
  }
23
23
 
24
+ // Task-comment events use the same rolling envelope variants as task events,
25
+ // with the comment itself nested under `comment`. Keep transport normalization
26
+ // in one place so the watcher can make ownership and sender decisions from a
27
+ // stable shape.
28
+ export function taskCommentFromEvent(payload) {
29
+ if (!payload || typeof payload !== 'object') return null
30
+ const envelopes = [payload, payload.data, payload.payload].filter((value) => value && typeof value === 'object' && !Array.isArray(value))
31
+ const source = Object.assign({}, ...envelopes.slice().reverse())
32
+ const comment = envelopes.map((value) => value.comment).find((value) => value && typeof value === 'object' && !Array.isArray(value))
33
+ if (!comment) return null
34
+ return {
35
+ projectId: source.project_id ?? source.projectId ?? comment.project_id ?? comment.projectId ?? null,
36
+ taskId: source.task_id ?? source.taskId ?? comment.task_id ?? comment.taskId ?? null,
37
+ comment,
38
+ }
39
+ }
40
+
24
41
  export function taskAgentId(task) {
25
42
  if (!task || typeof task !== 'object') return null
26
43
  return task.agent_id ?? task.agentId ?? task.agent?.id ?? task.assigned_agent?.id ?? task.assignedAgent?.id ?? null
@@ -397,8 +414,8 @@ export function normalizeRenderedMessageText(value) {
397
414
 
398
415
  export function messageSenderIsSelf(row, identity = {}) {
399
416
  if (!row || typeof row !== 'object') return false
400
- const sender = [row.sender, row.user, row.author, row.member].find((item) => item && typeof item === 'object') || {}
401
- const expanded = [row.senderAgent, row.sender_agent, row.agent, sender.agent].find((item) => item && typeof item === 'object')
417
+ const sender = [row.taskSender, row.task_sender, row.sender, row.user, row.author, row.member].find((item) => item && typeof item === 'object') || {}
418
+ const expanded = [row.taskSenderAgent, row.task_sender_agent, row.senderAgent, row.sender_agent, row.agent, sender.agent].find((item) => item && typeof item === 'object')
402
419
  const senderKind = String(sender.type ?? sender.kind ?? sender.sender_type ?? row.sender_type ?? row.author_type ?? '').toLowerCase()
403
420
  const senderLooksLikeAgent = agentSenderKind(senderKind) || (!humanSenderKind(senderKind) && (sender.agent_id != null || sender.identifier != null || (sender.slug != null && !sender.email)))
404
421
  const agent = expanded || (senderLooksLikeAgent ? sender : {})
@@ -484,8 +501,8 @@ function conversationMentions(value, selfAliases) {
484
501
 
485
502
  export function messageSenderIsAgent(message) {
486
503
  const m = message && typeof message === 'object' ? message : {}
487
- if (m.senderAgent || m.sender_agent || m.agent || m.agent_id != null || m.sender_agent_id != null || m.senderAgentId != null) return true
488
- const sender = [m.sender, m.user, m.author, m.member].find((item) => item && typeof item === 'object') || {}
504
+ if (m.taskSenderAgent || m.task_sender_agent || m.senderAgent || m.sender_agent || m.agent || m.agent_id != null || m.sender_agent_id != null || m.senderAgentId != null) return true
505
+ const sender = [m.taskSender, m.task_sender, m.sender, m.user, m.author, m.member].find((item) => item && typeof item === 'object') || {}
489
506
  if (sender.agent && typeof sender.agent === 'object') return true
490
507
  const kind = String(sender.type ?? sender.kind ?? sender.sender_type ?? m.sender_type ?? m.author_type ?? '').toLowerCase()
491
508
  return agentSenderKind(kind) || (!humanSenderKind(kind) && (sender.agent_id != null || sender.identifier != null || (sender.slug != null && !sender.email)))
@@ -506,7 +523,17 @@ export function classifyConversationTarget(message, selfAliases, { threadOwned =
506
523
 
507
524
  if (!text) return { action: 'ignore', reason: 'empty-message', text, explicitSelf, otherMentions: [] }
508
525
 
509
- if (messageSenderIsAgent(m) && !explicitSelf) {
526
+ // A peer agent may continue a conversation without repeating @name on every
527
+ // turn. Admit only a concrete question or request in a thread this watcher
528
+ // already owns; routine agent status chatter remains silent. This keeps BYO
529
+ // collaboration usable without turning every shared-channel post into an
530
+ // agent-to-agent reply loop.
531
+ const peerFollowUp = messageSenderIsAgent(m) && !explicitSelf && threadOwned && (
532
+ /\?/.test(text) ||
533
+ /\b(?:can|could|would|will|do|did|are|have)\s+you\b/i.test(text) ||
534
+ /^\s*(?:thanks[,!.]?\s+)?(?:please\s+)?(?:review|check|confirm|explain|share|send|post|help|continue|take|handle|onboard)\b/i.test(text)
535
+ )
536
+ if (messageSenderIsAgent(m) && !explicitSelf && !peerFollowUp) {
510
537
  return { action: 'ignore', reason: 'other-agent-chatter', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
511
538
  }
512
539
  if (!explicitSelf && otherMentions.length) {
@@ -531,7 +558,7 @@ export function classifyConversationTarget(message, selfAliases, { threadOwned =
531
558
  // chatter were handled above and still fail closed.
532
559
  if (!explicitSelf) {
533
560
  if (threadOwned) {
534
- return { action: 'handle', reason: 'owned-thread-follow-up', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
561
+ return { action: 'handle', reason: peerFollowUp ? 'owned-peer-follow-up' : 'owned-thread-follow-up', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
535
562
  }
536
563
  return { action: 'ignore', reason: 'unaddressed-thread-activity', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
537
564
  }
@@ -557,12 +584,17 @@ export function conversationNeedsCode(value, { rootContent = '' } = {}) {
557
584
 
558
585
  export function conversationAsksPendingTickets(value) {
559
586
  // Project prefixes such as OPEN-77 are identifiers, not the word “open”.
560
- // A question about a finished ticket must not become a backlog pickup.
587
+ // A question about a finished ticket must not become a backlog pickup. Keep
588
+ // all three signals in the same sentence/clause so an onboarding message that
589
+ // states "I have zero assigned tickets" and later asks about team cadence does
590
+ // not get hijacked into the pending-ticket shortcut.
561
591
  const text = String(value || '').replace(/\b[a-z][a-z0-9]*-\d+\b/gi, '').replace(/\s+/g, ' ').trim()
562
- if (!/\b(?:ticket|tickets|task|tasks)\b/i.test(text)) return false
563
- const pending = /\b(?:pending|open|assigned|backlog|outstanding|in[ -]?progress|working on)\b/i.test(text)
564
- const question = /\?|\b(?:any|what|which|how many|do you|have you|are there|show|list|check|verify|asked|whether)\b/i.test(text)
565
- return pending && question
592
+ return text.split(/(?<=[.!?])\s+|[;\n]+/).some((clause) => {
593
+ const tasks = /\b(?:ticket|tickets|task|tasks)\b/i.test(clause)
594
+ const pending = /\b(?:pending|open|assigned|backlog|outstanding|in[ -]?progress|working on)\b/i.test(clause)
595
+ const question = /\?|\b(?:what|which|how many|do you|have you|are there|show|list|check|verify|tell me|asked|whether|can you|could you|would you)\b/i.test(clause)
596
+ return tasks && pending && question
597
+ })
566
598
  }
567
599
 
568
600
  // A guarded reply normally has post_message removed so the runtime cannot race
package/src/watch.mjs CHANGED
@@ -10,7 +10,7 @@ import { fileURLToPath } from 'node:url'
10
10
  import { join, dirname } from 'node:path'
11
11
  import { OV_DIR, DEFAULT_WORKSPACE, readConfig, writeJson, configPath, onPath, fail, ok, info, slugify, stripSlash, chmodSafe } from './lib.mjs'
12
12
  import { connectAgentWs, assertWebSocket } from './ws.mjs'
13
- import { buildTaskCompletionReport, claudeEventEvidence, classifyConversationTarget, conversationAsksPendingTickets, conversationCreatesTicket, conversationNeedsCode, conversationRequestsChannelMessage, failedTaskRevisionIsCurrent, mentionDedupeKeys, messageParentId, messageSenderIsSelf, normalizeRenderedMessageText, renderedAgentMessages, taskAgentId, taskAgentIdentifier, taskBelongsToAgent, taskFromEvent, taskIsAwaitingReview, taskIsCompleted, taskIsCoordinationOnly, taskRevision, ticketDisplaySlug } from './events.mjs'
13
+ import { buildTaskCompletionReport, claudeEventEvidence, classifyConversationTarget, conversationAsksPendingTickets, conversationCreatesTicket, conversationNeedsCode, conversationRequestsChannelMessage, failedTaskRevisionIsCurrent, mentionDedupeKeys, messageParentId, messageSenderIsAgent, messageSenderIsSelf, normalizeRenderedMessageText, renderedAgentMessages, taskAgentId, taskAgentIdentifier, taskBelongsToAgent, taskCommentFromEvent, taskFromEvent, taskIsAwaitingReview, taskIsCompleted, taskIsCoordinationOnly, taskRevision, ticketDisplaySlug } from './events.mjs'
14
14
  import { WORK_SESSION_TOOL, workSessionRequest, agentProfileContext } from './runtime-control.mjs'
15
15
  import { createMastraMemory } from './memory.mjs'
16
16
  import { createContextService } from './context-service.mjs'
@@ -619,7 +619,10 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
619
619
  // Activity belongs to an individual cycle. Independent ticket workers must
620
620
  // not clear or overwrite each other's status targets.
621
621
  const replyStatusTargets = new Set()
622
- const activity = dependencies.activityReporter || createActivityReporter({ backend, apiKey, identifier, log })
622
+ const activity = dependencies.activityReporter || createActivityReporter({
623
+ backend, apiKey, identifier, log,
624
+ sendFrame: (frame) => handle?.send?.(frame) === true,
625
+ })
623
626
  const sendStatus = (channelId, state) => {
624
627
  const id = cycleContext.getStore()?.cycleId
625
628
  const live = liveCycles.get(id)
@@ -732,9 +735,18 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
732
735
  }),
733
736
  reply: createCycleQueue({
734
737
  concurrency: 3,
735
- groupKey: (item) => item.taskRef ? `ticket:${item.taskRef.projectId}:${item.taskRef.ticketId}` : `thread:${item.delivery?.channelId}:${item.delivery?.parentId}`,
738
+ groupKey: (item) => item.taskRef
739
+ ? `ticket:${item.taskRef.projectId}:${item.taskRef.ticketId}`
740
+ : item.delivery?.surface === 'task-comment'
741
+ ? `ticket:${item.delivery.projectId}:${item.delivery.ticketId}:comments`
742
+ : `thread:${item.delivery?.channelId}:${item.delivery?.parentId}`,
736
743
  run: async (item) => {
737
- const key = JSON.stringify([item.taskRef ? `ticket:${item.taskRef.projectId}:${item.taskRef.ticketId}` : `thread:${item.delivery?.channelId}:${item.delivery?.parentId}`, item.kind === 'coord'])
744
+ const source = item.taskRef
745
+ ? `ticket:${item.taskRef.projectId}:${item.taskRef.ticketId}`
746
+ : item.delivery?.surface === 'task-comment'
747
+ ? `ticket:${item.delivery.projectId}:${item.delivery.ticketId}:comments`
748
+ : `thread:${item.delivery?.channelId}:${item.delivery?.parentId}`
749
+ const key = JSON.stringify([source, item.kind === 'coord'])
738
750
  const runner = replyRunners.acquire(key, { key, canCoordinate: item.kind === 'coord' })
739
751
  activeReplyRunners.add(runner)
740
752
  item.control.runner = runner
@@ -760,7 +772,6 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
760
772
  try { replayState = JSON.parse(readFileSync(replayPath, 'utf8')) } catch { /* first run */ }
761
773
  const seenMentions = new Set(Array.isArray(replayState.seenMentions) ? replayState.seenMentions : [])
762
774
  const recentMentionSignatures = new Map(Array.isArray(replayState.recentMentionSignatures) ? replayState.recentMentionSignatures : [])
763
- const seenActivities = new Set(Array.isArray(replayState.seenActivities) ? replayState.seenActivities : [])
764
775
  const deliveredReplies = new Set(Array.isArray(replayState.deliveredReplies) ? replayState.deliveredReplies : [])
765
776
  const pendingReplyDeliveries = new Map(Array.isArray(replayState.pendingReplyDeliveries) ? replayState.pendingReplyDeliveries : [])
766
777
  const memory = dependencies.memory || createMastraMemory({
@@ -808,7 +819,6 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
808
819
  writeJson(replayPath, {
809
820
  seenMentions: [...seenMentions],
810
821
  recentMentionSignatures: [...recentMentionSignatures],
811
- seenActivities: [...seenActivities],
812
822
  deliveredReplies: [...deliveredReplies],
813
823
  pendingReplyDeliveries: [...pendingReplyDeliveries],
814
824
  failedTaskVersions: [...failedTaskVersions],
@@ -898,8 +908,6 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
898
908
  // hoping poll_inbox re-surfaces the same item. Each queue entry retains that
899
909
  // source independently through work, continuation, and delivery.
900
910
  let backlogProbeBusy = false
901
- let activityProbeBusy = false
902
- let knownProjects = []
903
911
  let selfAgentId = null
904
912
  let selfOrganizationId = null
905
913
  let identityContext = memory.get?.(`identity:${identifier}`)?.meta?.context || agentProfileContext({}, identifier)
@@ -993,7 +1001,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
993
1001
  }).finally(() => { mcpToolDiscoveryPromise = null })
994
1002
  return mcpToolDiscoveryPromise
995
1003
  }
996
- const callOptionalTicketComment = async ({ projectId, ticketId, content }) => {
1004
+ const callOptionalTicketComment = async ({ projectId, ticketId, content, parentId }) => {
997
1005
  let names
998
1006
  try { names = await discoverMcpTools() }
999
1007
  catch (e) {
@@ -1007,7 +1015,13 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1007
1015
  // reached the server, so never try another comment endpoint after an error.
1008
1016
  const name = names.has('create_task_comment') ? 'create_task_comment' : names.has('comment_ticket') ? 'comment_ticket' : ''
1009
1017
  if (!name) return { called: false, reason: 'not-advertised' }
1010
- const args = { project_id: projectId, ticket_id: ticketId, ...(name === 'create_task_comment' ? { content } : { text: content }) }
1018
+ const args = {
1019
+ project_id: projectId,
1020
+ ticket_id: ticketId,
1021
+ ...(name === 'create_task_comment'
1022
+ ? { content, ...(validBackendId(parentId) ? { parent_id: Number(parentId) } : {}) }
1023
+ : { text: content }),
1024
+ }
1011
1025
  try { return { called: true, tool: name, result: await callMcpTool(name, args) } }
1012
1026
  catch (e) {
1013
1027
  if (/\b(?:unknown|missing|unsupported) tool\b|\btool\b.*\bnot found\b/i.test(String(e?.message || e))) {
@@ -1218,6 +1232,34 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1218
1232
  return run
1219
1233
  }
1220
1234
 
1235
+ // Task comments have no channel thread to inspect before delivery. Record the
1236
+ // delivery attempt before the mutation and use the comment id as the one-level
1237
+ // thread root. An ambiguous mutation error is never replayed automatically.
1238
+ const postTaskCommentOnce = async ({ key, projectId, ticketId, parentId, content, sourceKey = '' }) => {
1239
+ if (stopping) throw new Error('Watcher is stopping')
1240
+ const deliveryKey = String(key || '')
1241
+ const message = String(content || '').trim()
1242
+ if (!deliveryKey || !validBackendId(projectId) || !validBackendId(ticketId) || !validBackendId(parentId) || !message) return { posted: false, reason: 'invalid-delivery' }
1243
+ if (deliveredReplies.has(deliveryKey) || memory.has(deliveryKey, 'rendered')) {
1244
+ return { posted: false, reason: 'remembered' }
1245
+ }
1246
+ if (messageDeliveries.has(deliveryKey)) return messageDeliveries.get(deliveryKey)
1247
+ deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies)
1248
+ persistReplay()
1249
+ memory.remember({ key: deliveryKey, kind: 'delivery', state: 'attempted', summary: message, refs: { projectId: Number(projectId), ticketId: Number(ticketId), threadId: Number(parentId) }, meta: { surface: 'task-comment' } })
1250
+ const run = (async () => {
1251
+ const result = await callOptionalTicketComment({ projectId: Number(projectId), ticketId: Number(ticketId), parentId: Number(parentId), content: message })
1252
+ if (!result.called) {
1253
+ throw new Error('No task-comment tool is advertised by the backend')
1254
+ }
1255
+ memory.remember({ key: deliveryKey, kind: 'delivery', state: 'rendered', summary: message, refs: { projectId: Number(projectId), ticketId: Number(ticketId), threadId: Number(parentId) }, meta: { surface: 'task-comment' } })
1256
+ if (sourceKey) memory.connect(deliveryKey, sourceKey, 'responds_to')
1257
+ return { posted: true, result: result.result }
1258
+ })().finally(() => messageDeliveries.delete(deliveryKey))
1259
+ messageDeliveries.set(deliveryKey, run)
1260
+ return run
1261
+ }
1262
+
1221
1263
  async function publishWorkPlan(live, entries, acknowledgement = '') {
1222
1264
  const { control, delivery, taskRef } = live
1223
1265
  const steps = (Array.isArray(entries) ? entries : []).filter(entry => entry && typeof entry.content === 'string' && entry.content.trim()).slice(0, 3).map(entry => entry.content.trim().slice(0, 240))
@@ -1245,7 +1287,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1245
1287
  deliveryProbeBusy = true
1246
1288
  try {
1247
1289
  await mapConcurrent([...pendingReplyDeliveries.values()], 3, async (delivery) => {
1248
- try { await postMessageOnce(delivery) }
1290
+ try { await (delivery.surface === 'task-comment' ? postTaskCommentOnce(delivery) : postMessageOnce(delivery)) }
1249
1291
  catch (error) { log('reply remains pending: ' + (error?.message || error)) }
1250
1292
  })
1251
1293
  } finally { deliveryProbeBusy = false }
@@ -1438,87 +1480,6 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1438
1480
  return publishBlocker({ prompt, taskRef, delivery, notice, ticketNotice, pause: true })
1439
1481
  }
1440
1482
 
1441
- const replayRecentActivity = async (projects) => {
1442
- const mentionActivity = []
1443
- let activityReplayTouched = false
1444
- await ensureSelfAgent()
1445
- const mentionNeedles = [...selfAliases].map((alias) => '@' + alias)
1446
- await mapConcurrent(projects, 3, async (project) => {
1447
- const activityData = await callMcpTool('list_activity', { project_id: project.id }).then(toolData).catch((error) => {
1448
- log('activity reconciliation unavailable: ' + (error?.message || error))
1449
- return {}
1450
- })
1451
- const activities = Array.isArray(activityData.activities) ? activityData.activities : Array.isArray(activityData.activity) ? activityData.activity : []
1452
- for (const item of activities) {
1453
- const text = JSON.stringify(item)
1454
- const activityKey = String(project.id) + ':' + String(item.id ?? item.message_id ?? item.messageId ?? text.slice(0, 500))
1455
- const data = item?.data && typeof item.data === 'object' ? item.data : null
1456
- const activityMessage = item?.message && typeof item.message === 'object'
1457
- ? item.message
1458
- : data?.message && typeof data.message === 'object' ? data.message : item
1459
- const activityText = String(activityMessage?.content ?? activityMessage?.body ?? activityMessage?.text ?? '').toLowerCase()
1460
- const activityChannelId = item?.channel_id ?? item?.channelId ?? data?.channel_id ?? data?.channelId
1461
- const activityMessageId = activityMessage?.id ?? activityMessage?.message_id ?? activityMessage?.messageId
1462
- const activityParentId = messageParentId(activityMessage)
1463
- const activityThreadRoot = activityParentId != null ? activityParentId : activityMessageId
1464
- const activityControlKey = threadControlKey(activityChannelId, activityThreadRoot)
1465
- const ownedActivity = !!activityControlKey && memory.has(activityControlKey, 'active')
1466
- // Search only the source message, never the whole activity envelope: an
1467
- // old tagged thread root can otherwise make another agent's new reply
1468
- // look addressed to this watcher. Explicit mentions and human follow-ups
1469
- // in a persistently owned thread both enter the guarded per-message path.
1470
- const addressedActivity = mentionNeedles.some((needle) => activityText.includes(needle)) || ownedActivity || activityParentId != null
1471
- if (!seenActivities.has(activityKey) && addressedActivity && /message|mention|channel/i.test(text)) {
1472
- seenActivities.add(activityKey); trimSeen(seenActivities); activityReplayTouched = true
1473
- // The same logical mention may already have arrived over WebSocket.
1474
- // Share the id/signature guard instead of starting a second model turn.
1475
- // Recover the former pre-routing acknowledgement bug exactly once
1476
- // when an owned-thread source has no received ledger entry.
1477
- if (markMentionHandled(activityMessage, activityChannelId)) {
1478
- const activityMentionKeys = mentionDedupeKeys(activityMessage, activityChannelId)
1479
- const activitySourceKey = `mention:${activityChannelId ?? '?'}:${activityMentionKeys.idKey || activityMentionKeys.signatureKey || activityThreadRoot}`
1480
- if (memory.has(activitySourceKey, 'received')) continue
1481
- }
1482
- mentionActivity.push({
1483
- projectId: project.id,
1484
- project: project.name,
1485
- channelId: activityChannelId,
1486
- message: activityMessage,
1487
- activity: item, activityKey,
1488
- })
1489
- }
1490
- }
1491
- })
1492
- if (activityReplayTouched) persistReplay()
1493
- if (!mentionActivity.length) return
1494
- log('activity recovery found ' + mentionActivity.length + ' mention(s) -> guarded reply cycle(s)')
1495
- // Replay each real message through the exact same delivery path as a live
1496
- // WebSocket mention. This preserves thread ids and lets postMessageOnce
1497
- // consult the rendered thread before any reply is emitted.
1498
- for (const mention of mentionActivity) {
1499
- void onEvent('agent:mention', {
1500
- project_id: mention.projectId,
1501
- channel_id: mention.channelId,
1502
- message: mention.message,
1503
- _mentionAlreadyMarked: true,
1504
- })?.then((result) => {
1505
- if (result?.status === 'retry') { seenActivities.delete(mention.activityKey); persistReplay() }
1506
- })
1507
- }
1508
- }
1509
-
1510
- // The backend can occasionally omit a targeted WebSocket message. Keep a
1511
- // cached project list from connection recovery and check only recent activity
1512
- // while idle. This does not scan projects, boards, task types, or tickets and
1513
- // does not start a provider process unless a new addressed message is found.
1514
- const reconcileActivity = async () => {
1515
- if (stopping || !mcpUrl || backlogProbeBusy || activityProbeBusy || !knownProjects.length) return
1516
- activityProbeBusy = true
1517
- try { await replayRecentActivity(knownProjects) }
1518
- catch (e) { log('activity recovery failed: ' + (e?.message || e)) }
1519
- finally { activityProbeBusy = false }
1520
- }
1521
-
1522
1483
  // Reconcile everything that may have arrived while disconnected. This spends no
1523
1484
  // model tokens unless the tools actually report pending work.
1524
1485
  const reconcileBacklog = async () => {
@@ -1528,7 +1489,6 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1528
1489
  try {
1529
1490
  const projectsData = toolData(await callMcpTool('list_projects'), 'list_projects')
1530
1491
  const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
1531
- knownProjects = projects.map((project) => ({ id: project.id, name: project.name }))
1532
1492
  await mapConcurrent(projects, 3, async (project) => {
1533
1493
  try {
1534
1494
  const assigned = []
@@ -1580,14 +1540,13 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1580
1540
  }
1581
1541
  } catch (error) { log('Project reconciliation failed: ' + (error?.message || error)) }
1582
1542
  })
1583
- await replayRecentActivity(projects)
1584
1543
  } catch (e) {
1585
1544
  log('backlog reconciliation failed: ' + (e && e.message ? e.message : e))
1586
1545
  } finally { backlogProbeBusy = false }
1587
1546
  }
1588
1547
 
1589
1548
  const baseFor = (kind, delivery) => delivery?.watcherOwned
1590
- ? (kind === 'full' ? fullPrompt + '\n\n' + guardedReplyPrompt : guardedReplyPrompt)
1549
+ ? (kind === 'full' ? fullPrompt + '\n\n' + guardedReplyPrompt : guardedReplyPrompt) + (delivery.surface === 'task-comment' ? '\n\nTASK COMMENT DELIVERY: return the final response for the watcher to post as a reply to the source task comment. Do not call post_message, create_task_comment, or comment_ticket yourself.' : '')
1591
1550
  : kind === 'intro' ? INTRO : kind === 'full' ? fullPrompt : (kind === 'coord' || kind === 'sweep') ? coordinatePrompt : fastPrompt
1592
1551
  // A native final turn ends a cycle. Ticket completion is a separate board
1593
1552
  // action chosen by the agent; tool counts never determine either decision.
@@ -1612,7 +1571,8 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1612
1571
  if (key != null && queues[laneName].has(key)) return queues[laneName].enqueue(null, key)
1613
1572
  const cycleId = `${journal.runId}:cycle:${++cycleSequence}`
1614
1573
  const control = { cycleId, outcome: 'queued', cancelled: false, runner: null, statusTargets: new Set(), enqueuedAt: performance.now() }
1615
- const cycleData = { cycleId, kind, lane: laneName, model: kind === 'full' ? codeModel : liteModel, workdir: itemWorkdir || (laneName === 'work' ? workdir : ''), ...(taskRef ? { ticket: { projectId: taskRef.projectId, ticketId: taskRef.ticketId } } : {}), ...(delivery ? { thread: { channelId: delivery.channelId, threadId: delivery.parentId } } : {}) }
1574
+ const deliveryTicket = delivery?.surface === 'task-comment' ? { projectId: delivery.projectId, ticketId: delivery.ticketId } : null
1575
+ const cycleData = { cycleId, kind, lane: laneName, model: kind === 'full' ? codeModel : liteModel, workdir: itemWorkdir || (laneName === 'work' ? workdir : ''), ...(taskRef ? { ticket: { projectId: taskRef.projectId, ticketId: taskRef.ticketId } } : deliveryTicket ? { ticket: deliveryTicket } : {}), ...(delivery?.surface !== 'task-comment' && delivery ? { thread: { channelId: delivery.channelId, threadId: delivery.parentId } } : {}) }
1616
1576
  liveCycles.set(cycleId, { data: cycleData, control, taskRef, delivery })
1617
1577
  emit('cycle.queued', { ...cycleData, status: 'queued' })
1618
1578
  return queues[laneName].enqueue({ kind, context, targetChannels, taskRef, delivery, workdir: itemWorkdir, control }, key).then((result) => {
@@ -1670,9 +1630,12 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1670
1630
  const activeTaskRef = taskRef
1671
1631
  let verifiedTaskRevision = ''
1672
1632
  const ticketWorktree = preparedWorkdir || (kind === 'full' && activeTaskRef ? findTicketWorktree(workdir, activeTaskRef.ticketId) : '')
1633
+ const guardedTools = delivery?.surface === 'task-comment'
1634
+ ? ['post_message', 'create_task_comment', 'comment_ticket']
1635
+ : delivery?.watcherOwned && !delivery?.allowChannelMessaging ? ['post_message'] : []
1673
1636
  const runnerOptions = {
1674
1637
  cycleId: cycleControl.cycleId, attempt: cycleControl.attempt || 1,
1675
- ...(delivery?.watcherOwned && !delivery?.allowChannelMessaging ? { disabledMcpTools: ['post_message'] } : {}),
1638
+ ...(guardedTools.length ? { disabledMcpTools: guardedTools } : {}),
1676
1639
  ...(ticketWorktree ? { workdir: ticketWorktree } : {}),
1677
1640
  }
1678
1641
  const targets = [...new Set(targetChannels.filter((id) => id != null && Number.isFinite(Number(id))).map(Number))]
@@ -1683,8 +1646,10 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1683
1646
  }
1684
1647
  // credNote + charter live in the cached system prompt now — the per-cycle
1685
1648
  // message is just the event context + the small base instruction.
1686
- const memoryRefs = delivery
1687
- ? { channelId: delivery.channelId, threadId: delivery.parentId }
1649
+ const memoryRefs = delivery?.surface === 'task-comment'
1650
+ ? { projectId: delivery.projectId, ticketId: delivery.ticketId, threadId: delivery.parentId }
1651
+ : delivery
1652
+ ? { channelId: delivery.channelId, threadId: delivery.parentId }
1688
1653
  : activeTaskRef ? { projectId: activeTaskRef.projectId, ticketId: activeTaskRef.ticketId } : {}
1689
1654
  let recalled = ''
1690
1655
  try { recalled = await memory.context(memoryRefs) }
@@ -1812,7 +1777,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1812
1777
  log('guarded reply produced no final text; leaving delivery unrecorded for retry')
1813
1778
  } else {
1814
1779
  const deliveryStartedAt = performance.now()
1815
- try { await postMessageOnce({ ...delivery, content: reply }) }
1780
+ try { await (delivery.surface === 'task-comment' ? postTaskCommentOnce({ ...delivery, content: reply }) : postMessageOnce({ ...delivery, content: reply })) }
1816
1781
  catch (e) { log('guarded reply delivery failed closed: ' + (e?.message || e)) }
1817
1782
  finally { log(`cycle timing delivery=${Math.round(performance.now() - deliveryStartedAt)}ms kind=${kind}`) }
1818
1783
  }
@@ -1826,9 +1791,9 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1826
1791
  // Distill an event's WS payload into a directive the model can act on without
1827
1792
  // any extra lookup.
1828
1793
  const senderName = (m) => {
1829
- const s = m && (m.sender || m.user || m.author)
1794
+ 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)
1830
1795
  if (!s) return ''
1831
- return (`${s.first_name || ''} ${s.last_name || ''}`.trim() || s.name || s.email || '')
1796
+ return (`${s.first_name || s.firstName || ''} ${s.last_name || s.lastName || ''}`.trim() || s.name || s.display_name || s.identifier || s.email || '')
1832
1797
  }
1833
1798
 
1834
1799
  // Route only concrete repository requests to the coding lane. Conversation
@@ -1934,6 +1899,71 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1934
1899
  }
1935
1900
  }
1936
1901
 
1902
+ const handleTaskComment = async (raw) => {
1903
+ const event = taskCommentFromEvent(raw)
1904
+ const projectId = event?.projectId
1905
+ const ticketId = event?.taskId
1906
+ const comment = event?.comment
1907
+ const commentId = comment && (comment.id ?? comment.comment_id ?? comment.commentId)
1908
+ const text = String(comment?.content ?? comment?.body ?? comment?.text ?? comment?.message ?? '').replace(/\s+/g, ' ').trim()
1909
+ if (!validBackendId(projectId) || !validBackendId(ticketId) || !validBackendId(commentId) || !text) {
1910
+ log('task:comment missing project/task/comment identity or content — ignored')
1911
+ return { status: 'ignored-malformed' }
1912
+ }
1913
+ const sourceKey = `task-comment:${Number(projectId)}:${Number(ticketId)}:${Number(commentId)}`
1914
+ if (memory.has(sourceKey, 'received')) {
1915
+ log(`task:comment duplicate project=${projectId} ticket=${ticketId} comment=${commentId} — skipped`)
1916
+ return { status: 'duplicate' }
1917
+ }
1918
+ try { await ensureSelfAgent() }
1919
+ catch (error) { log('agent identity lookup unavailable; using configured aliases: ' + (error?.message || error)) }
1920
+ if (messageSenderIsSelf(comment, { id: selfAgentId, identifier, slug, name })) return { status: 'ignored-self' }
1921
+ const target = classifyConversationTarget(comment, [...selfAliases], { threadOwned: true })
1922
+ if (target.action !== 'handle') {
1923
+ log('task:comment ignored before model start (' + target.reason + ')')
1924
+ return { status: 'ignored-recipient' }
1925
+ }
1926
+ try {
1927
+ const tools = await discoverMcpTools()
1928
+ if (!tools.has('create_task_comment') && !tools.has('comment_ticket')) {
1929
+ log('task:comment reply tool is not advertised — ignored before model start')
1930
+ return { status: 'ignored-no-delivery-tool' }
1931
+ }
1932
+ const ticketData = toolData(await callMcpTool('get_ticket', { project_id: Number(projectId), ticket_id: Number(ticketId) }), 'get_ticket')
1933
+ const ticket = ticketData.ticket ?? ticketData.task ?? ticketData
1934
+ if (String(ticket.id) !== String(ticketId) || (ticket.project_id != null && String(ticket.project_id) !== String(projectId))) {
1935
+ log('task:comment get_ticket returned mismatched identity — ignored')
1936
+ return { status: 'ignored-mismatch' }
1937
+ }
1938
+ if (!await resolveTaskOwner(ticket)) {
1939
+ log(`task:comment ticket #${ticketId} is not assigned to this agent — ignored`)
1940
+ return { status: 'ignored-unassigned' }
1941
+ }
1942
+ const rootCommentId = messageParentId(comment) ?? Number(commentId)
1943
+ const who = senderName(comment)
1944
+ const peer = messageSenderIsAgent(comment)
1945
+ const ticketSlug = ticketDisplaySlug(ticket)
1946
+ memory.remember({ key: sourceKey, kind: 'task-comment', state: 'received', summary: text, refs: { projectId: Number(projectId), ticketId: Number(ticketId), messageId: Number(commentId), threadId: Number(rootCommentId) } })
1947
+ const delivery = {
1948
+ key: `reply:${sourceKey}`,
1949
+ surface: 'task-comment',
1950
+ projectId: Number(projectId),
1951
+ ticketId: Number(ticketId),
1952
+ parentId: Number(rootCommentId),
1953
+ sourceKey,
1954
+ watcherOwned: true,
1955
+ }
1956
+ const label = ticketSlug || String(ticket.title || 'the assigned ticket')
1957
+ 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.' : ''}`
1958
+ log(`task:comment project=${projectId} ticket=${ticketId} comment=${commentId} -> coordination lane`)
1959
+ void drain('coord', context, [], null, delivery)
1960
+ return { status: 'queued' }
1961
+ } catch (error) {
1962
+ log(`task:comment verification failed for ticket #${ticketId}: ` + (error?.message || error))
1963
+ return { status: 'retry' }
1964
+ }
1965
+ }
1966
+
1937
1967
  const conversationEventData = (value) => {
1938
1968
  const outer = value && typeof value === 'object' && !Array.isArray(value) ? value : {}
1939
1969
  const data = outer.data && typeof outer.data === 'object' && !Array.isArray(outer.data) ? outer.data : {}
@@ -1950,6 +1980,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1950
1980
  const mentionIntake = new Map()
1951
1981
  function onEvent(k, d) {
1952
1982
  if (stopping) return
1983
+ if (k === 'task:comment') return trackOperation(handleTaskComment(d))
1953
1984
  const conversationEvent = k === 'agent:mention' || k === 'agent:message' || k === 'channel:message'
1954
1985
  if (!conversationEvent) return trackOperation(routeEvent(k, d))
1955
1986
  const eventData = conversationEventData(d)
@@ -2016,11 +2047,14 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
2016
2047
  const raw = d && typeof d === 'object' ? d : {}
2017
2048
  if (k === 'task:created' || k === 'task:assigned' || k === 'task:updated') {
2018
2049
  return handleTaskSignal(k, raw)
2050
+ } else if (k === 'task:comment') {
2051
+ return handleTaskComment(raw)
2019
2052
  } else if (k === 'agent:mention') {
2020
2053
  const cid = raw.channel_id != null ? raw.channel_id : (raw.channelId != null ? raw.channelId : null)
2021
2054
  const msg = raw.message && typeof raw.message === 'object' ? raw.message : {}
2022
2055
  const text = String(msg.content || msg.body || msg.text || '').replace(/\s+/g, ' ')
2023
2056
  const who = senderName(msg)
2057
+ const peerMessage = messageSenderIsAgent(msg)
2024
2058
  // Reply IN THE SAME THREAD: parent is the thread root (the message's own id
2025
2059
  // for a top-level mention, or its parent when the mention is itself a reply).
2026
2060
  const mid = msg.message_id ?? msg.messageId ?? msg.id ?? null
@@ -2043,10 +2077,10 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
2043
2077
  const target = classifyConversationTarget(msg, [...selfAliases], { threadOwned })
2044
2078
  if (target.action === 'ignore') {
2045
2079
  log('agent:mention ignored before model start (' + target.reason + ')')
2046
- // A redirect or another agent taking over invalidates any older work or
2047
- // draft still queued for this thread. The event name alone cannot retain
2048
- // ownership after the actual message says otherwise.
2049
- if (cid != null && threadRoot != null && (target.reason === 'explicit-other-recipient' || target.reason === 'other-agent-chatter' || target.reason === 'redirected-to-later-agent')) {
2080
+ // Ordinary peer chatter is additional thread context, not cancellation.
2081
+ // Only a message whose recipient analysis shows an actual handoff may
2082
+ // invalidate older work; explicit stand-down is handled below.
2083
+ if (cid != null && threadRoot != null && (target.reason === 'explicit-other-recipient' || target.reason === 'redirected-to-later-agent')) {
2050
2084
  cancelThread(cid, threadRoot, text || target.reason)
2051
2085
  }
2052
2086
  return
@@ -2141,6 +2175,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
2141
2175
  : `The watcher verified this source message is addressed to you in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". ${codingMention ? `This is concrete repository work. Start the coding flow now. You may send at most one concrete progress update after work begins, but do not stop there: continue the work and then send one distinct final result with evidence or a real blocker. Use post_message for the final result even if you already sent a progress message` : 'Send EXACTLY ONE reply with post_message'}: arguments: channel_id ${cid}${threadRoot != null ? `, parent_id ${threadRoot} (reply IN THAT THREAD, do not start a new top-level message)` : ''}, using the authenticated session bridge. Never repeat the same message.${who ? ` To @mention them back, write their EXACT full name "@${who}". A mention only links when the name matches exactly.` : ''} Use additional discovery, resources, and history when they help answer this request.${codingMention ? ' Stop only after the work and final result/blocker.' : ' After your single reply, STOP.'}`
2142
2176
  : undefined
2143
2177
  if (ctx && creatingTicket) ctx += ' This is a ticket-creation request, not repository implementation. Discover create_ticket on the authenticated watcher server, resolve the project, board, and valid type using list_projects, list_boards, and list_task_types, then create the requested ticket once. Follow its actual schema. If a required detail is ambiguous, ask for that detail rather than inventing it. Report the returned ticket reference. Never replay a creation after a timeout or uncertain response.'
2178
+ if (ctx && peerMessage) ctx += ' This request was authored by another team agent. Treat its concrete question or request as real team coordination. Answer the substance directly. Do not replace it with your own ticket inventory, and do not append a reciprocal question or open-ended invitation unless the source explicitly asks for one.'
2144
2179
  if (ctx && raw._threadRootContent) ctx += ` Verified thread root authored by you (context, not a new request): ${JSON.stringify(raw._threadRootContent)}. For requested repairs, inspect the referenced PR and its branch, run the failing verification, and fix that existing work. A review/testing handoff does not prevent an explicit human repair request.`
2145
2180
  if (codingMention) {
2146
2181
  // Activity indicators make accepted work visible. Do not add a canned
@@ -2157,7 +2192,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
2157
2192
  }
2158
2193
 
2159
2194
  // The WebSocket drives real-time reactions (task:created/updated assigned to us,
2160
- // agent:mention, agent:message, and channel:message). Verified organization subscriptions
2195
+ // task:comment, agent:mention, agent:message, and channel:message). Verified organization subscriptions
2161
2196
  // recover channel mentions even when the backend omits targeted dispatch.
2162
2197
  // All conversation event types share recipient, ownership and deduplication guards.
2163
2198
  log('up — backend WS watcher on ' + wsUrl + (canCode ? ' [code: ' + workdir + ']' : ''))
@@ -2223,10 +2258,6 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
2223
2258
  }
2224
2259
  const deliveryTimer = autoStart ? setInterval(() => void flushPendingReplies(), 30_000) : null
2225
2260
  deliveryTimer?.unref?.()
2226
- const activityRecoveryTimer = autoStart ? setInterval(() => {
2227
- if (!stopping) void trackOperation(reconcileActivity())
2228
- }, 30_000) : null
2229
- activityRecoveryTimer?.unref?.()
2230
2261
  const authorizationTimer = autoStart ? setInterval(() => void resumeReadyAuthorizations(), 2_000) : null
2231
2262
  authorizationTimer?.unref?.()
2232
2263
  let modelSettingsWatcher = null
@@ -2266,7 +2297,6 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
2266
2297
  closePromise = (async () => {
2267
2298
  clearInterval(authorizationTimer)
2268
2299
  clearInterval(deliveryTimer)
2269
- clearInterval(activityRecoveryTimer)
2270
2300
  modelSettingsWatcher?.close()
2271
2301
  if (introTimer) clearTimeout(introTimer)
2272
2302
  try { handle?.close() } catch { /* already disconnected */ }
@@ -2291,7 +2321,6 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
2291
2321
  }
2292
2322
  return {
2293
2323
  done, close, receive: onEvent, reconcile: () => stopping ? Promise.resolve() : trackOperation(reconcileBacklog()),
2294
- recoverActivity: () => stopping ? Promise.resolve() : trackOperation(reconcileActivity()),
2295
2324
  resumeAuthorizations: resumeReadyAuthorizations, retryDeliveries: flushPendingReplies,
2296
2325
  get pending() { return queues.work.size + queues.reply.size + mentionIntake.size },
2297
2326
  }
package/src/ws.mjs CHANGED
@@ -18,6 +18,31 @@ const KEEPALIVE_MS = 30_000 // refresh last_seen_at well within any idle timeout
18
18
  const BACKOFF_MIN_MS = 1_000
19
19
  const BACKOFF_MAX_MS = 30_000
20
20
 
21
+ const own = (value, key) => Object.prototype.hasOwnProperty.call(value, key)
22
+
23
+ /** Accept the documented `{k,d}` event and compatible backend relay envelopes.
24
+ * Keeping this at the transport boundary prevents provider/watch logic from
25
+ * learning several wire formats. */
26
+ export function normalizeAgentWsEvent(raw) {
27
+ let msg = raw
28
+ if (typeof msg === 'string') { try { msg = JSON.parse(msg) } catch { return null } }
29
+ if (!msg || typeof msg !== 'object' || Array.isArray(msg)) return null
30
+ if (!msg.k && !msg.kind && !msg.event && !msg.event_type && !msg.eventType && !msg.type && own(msg, 'body')) {
31
+ return normalizeAgentWsEvent(msg.body)
32
+ }
33
+ const kind = [msg.k, msg.kind, msg.event, msg.event_type, msg.eventType, msg.type].find(value => typeof value === 'string' && value.trim())
34
+ if (!kind || kind === 'event') return null
35
+ let payload
36
+ if (own(msg, 'd')) payload = msg.d
37
+ else if (own(msg, 'payload')) payload = msg.payload
38
+ else if (own(msg, 'data')) payload = msg.data
39
+ else {
40
+ const { k: _k, kind: _kind, event: _event, event_type: _eventType, eventType: _eventTypeCamel, type: _type, ...body } = msg
41
+ payload = body
42
+ }
43
+ return { kind, payload, envelope: msg }
44
+ }
45
+
21
46
  /** Bail early with a friendly message if the runtime has no global WebSocket. */
22
47
  export function assertWebSocket(fail) {
23
48
  if (typeof globalThis.WebSocket === 'function') return
@@ -86,13 +111,12 @@ export function connectAgentWs({ wsUrl, apiKey, identifier, onEvent, onConnect,
86
111
 
87
112
  sock.addEventListener('message', (ev) => {
88
113
  if (sock !== ws) return
89
- let msg
90
- try { msg = JSON.parse(typeof ev.data === 'string' ? ev.data : String(ev.data)) } catch { return }
91
- const kind = msg && msg.k
114
+ const event = normalizeAgentWsEvent(typeof ev.data === 'string' ? ev.data : String(ev.data))
115
+ const kind = event?.kind
92
116
  if (!kind || kind === 'keepalive:ack') return // keepalive round-trip, nothing to do
93
117
  // Error metadata often lives on the JSON envelope rather than `d`. Preserve
94
118
  // it so callers do not receive an unhelpful empty object.
95
- try { onEvent(kind, kind === 'error' ? msg : msg.d) } catch (e) { log('event handler error: ' + (e && e.message ? e.message : e)) }
119
+ try { onEvent(kind, kind === 'error' ? event.envelope : event.payload) } catch (e) { log('event handler error: ' + (e && e.message ? e.message : e)) }
96
120
  })
97
121
 
98
122
  const down = (why) => {
@@ -119,8 +143,14 @@ export function connectAgentWs({ wsUrl, apiKey, identifier, onEvent, onConnect,
119
143
  subscribe()
120
144
  return true
121
145
  },
122
- /** Fire-and-forget send (no-op if the socket isn't open). */
123
- send(obj) { try { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)) } catch { /* closing */ } },
146
+ /** Fire-and-forget send. Returns whether the frame reached an open socket. */
147
+ send(obj) {
148
+ try {
149
+ if (!ws || ws.readyState !== 1) return false
150
+ ws.send(JSON.stringify(obj))
151
+ return true
152
+ } catch { return false }
153
+ },
124
154
  close() {
125
155
  closed = true
126
156
  clearKeepalive()
package/studio/style.css CHANGED
@@ -139,18 +139,19 @@
139
139
  .section-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 4px 2px 15px; min-height: 37px; }
140
140
  .section-heading h2 { font-size: 12px; }
141
141
  .section-heading .muted { font-size: 12px; }
142
- .activity-list { display: flex; flex-direction: column; gap: 8px; max-height: min(67vh, 810px); min-height: 380px; overflow: auto; overscroll-behavior: contain; scrollbar-width: thin; padding: 2px; }
143
- .activity-row { display: flex; align-items: flex-start; gap: 11px; text-align: left; width: 100%; padding: 16px 14px; border: 1px solid transparent; border-radius: var(--radius-md); corner-shape: squircle; background: transparent; }
142
+ .activity-list { display: flex; flex-direction: column; gap: 2px; max-height: min(67vh, 810px); min-height: 380px; overflow: auto; overscroll-behavior: contain; scrollbar-width: thin; padding: 2px; }
143
+ .activity-row { display: flex; align-items: center; gap: 8px; text-align: left; width: 100%; min-height: 40px; padding: 6px 9px; border: 1px solid transparent; border-radius: 12px; corner-shape: squircle; background: transparent; }
144
144
  .activity-row:hover { background: var(--hover); }
145
145
  .activity-row.is-selected { background: var(--surface); border-color: transparent; box-shadow: 0 1px 2px #00000006; }
146
- .event-icon { width: 34px; height: 34px; display: grid; place-items: center; flex-shrink: 0; color: var(--muted); background: var(--surface); border: 1px solid var(--border); border-radius: 10px; corner-shape: squircle; margin-top: 1px; }
147
- .event-icon .icon { width: 18px; height: 18px; }
148
- .event-body { min-width: 0; flex: 1; }
149
- .event-title { display: block; font-size: 14px; font-weight: 500; line-height: 1.55; overflow-wrap: anywhere; }
150
- .event-description { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; font-size: 12px; font-weight: 400; color: var(--muted); margin-top: 4px; line-height: 1.7; overflow-wrap: anywhere; }
151
- .event-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 5px 8px; font-size: 12px; font-weight: 400; color: var(--muted); margin-top: 7px; }
152
- .event-meta .badge { font-size: 12px; padding: 1px 6px; }
153
- .event-time { font-size: 12px; color: var(--subtle); white-space: nowrap; font-weight: 400; font-family: var(--font-mono); margin-top: 4px; }
146
+ .event-icon { width: 27px; height: 27px; display: grid; place-items: center; flex-shrink: 0; color: var(--muted); background: var(--surface); border: 1px solid var(--border); border-radius: 8px; corner-shape: squircle; }
147
+ .event-icon .icon { width: 14px; height: 14px; }
148
+ .event-body { min-width: 0; flex: 1; display: flex; align-items: center; gap: 7px; overflow: hidden; }
149
+ .event-title { flex: 0 1 auto; max-width: 38%; font-size: 12px; font-weight: 500; line-height: 1.35; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
150
+ .event-description { flex: 1 1 auto; min-width: 24px; overflow: hidden; font-size: 11px; font-weight: 400; color: var(--muted); margin: 0; line-height: 1.35; white-space: nowrap; text-overflow: ellipsis; }
151
+ .event-meta { display: flex; align-items: center; flex: 0 1 auto; min-width: 0; flex-wrap: nowrap; gap: 5px; font-size: 11px; font-weight: 400; color: var(--muted); margin: 0; white-space: nowrap; overflow: hidden; }
152
+ .event-meta > span:not(.badge) { max-width: 86px; overflow: hidden; text-overflow: ellipsis; }
153
+ .event-meta .badge { font-size: 10px; padding: 1px 5px; }
154
+ .event-time { font-size: 10px; color: var(--subtle); white-space: nowrap; font-weight: 400; font-family: var(--font-mono); margin: 0; }
154
155
  .badge { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; border-radius: 999px; background: var(--hover); color: var(--muted); font-size: 12px; font-weight: 400; white-space: nowrap; }
155
156
  .badge .dot { width: 4px; height: 4px; }
156
157
  .badge.active, .badge.completed, .badge.online { color: var(--success); }
@@ -353,10 +354,10 @@
353
354
  .guide-callout { padding: 18px; border-radius: var(--radius-md); background: var(--stage); }
354
355
  @media print { .guide-nav, .guide-header .button, .skip-link { display: none; } .guide-layout { display: block; } .guide-shell { max-width: none; padding: 0; } .guide-content section { break-inside: avoid; } }
355
356
  @media (max-width: 640px) { .guide-shell { padding: 24px 20px; } .guide-layout { grid-template-columns: minmax(0, 1fr); gap: 24px; } .guide-nav { position: static; } .panel-toolbar { flex-wrap: wrap; } }
356
- @media (min-width: 1550px) { .workspace { padding-bottom: 28px; } .content-grid { grid-template-columns: minmax(280px, .85fr) minmax(360px, 1.15fr); gap: 24px; } .activity-row { padding: 15px; } .inspector { padding: 2px; } }
357
+ @media (min-width: 1550px) { .workspace { padding-bottom: 28px; } .content-grid { grid-template-columns: minmax(280px, .85fr) minmax(360px, 1.15fr); gap: 24px; } .activity-row { padding: 6px 9px; } .inspector { padding: 2px; } }
357
358
  @media (max-width: 1120px) { .app-shell { grid-template-columns: 185px minmax(0, 1fr); gap: 20px; width: calc(100% - 40px); } .sidebar { padding-inline: 10px; } .workspace { padding: 8px 0 18px; } .content-grid { grid-template-columns: minmax(250px, 1fr) minmax(270px, 1fr); } .metrics { gap: 8px; } .header-actions { gap: 10px; } .metric-caption { font-size: 12px; } }
358
359
  @media (max-width: 940px) { .content-grid { grid-template-columns: minmax(0, 1fr); } .inspector { max-height: none; min-height: 220px; } .inspector-empty { min-height: 200px; } .activity-list { min-height: 250px; max-height: 440px; } .metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .page-header { align-items: flex-start; } .header-actions { flex-direction: column; align-items: flex-end; padding-top: 7px; gap: 13px; } .page-description { max-width: 230px; } }
359
- @media (max-width: 640px) { .app-shell { grid-template-columns: minmax(0, 1fr); width: 100%; margin: 0 auto; gap: 0; min-height: 100dvh; } .sidebar { position: static; height: auto; padding: 16px 18px 8px; border: 0; border-radius: 0; overflow-y: visible; } .brand { margin: 0 0 16px; font-size: 13px; } .brand-caption { display: none; } .brand-mark { width: 28px; height: 28px; } .brand-mark img { width: 56px; height: 56px; } .sidebar-title, .agent-search, .sidebar-note { display: none; } .agent-list { flex-direction: row; overflow-x: auto; padding: 2px 2px 7px; gap: 6px; } .agent-item { width: auto; min-width: 132px; max-width: 170px; padding: 8px; flex-shrink: 0; } .agent-avatar { width: 25px; height: 25px; font-size: 12px; } .agent-name { font-size: 12px; } .agent-caption { font-size: 12px; } .workspace { padding: 17px 18px 20px; } .page-header { margin-bottom: 22px; gap: 10px; } h1 { font-size: 27px; } .page-description { font-size: 12px; max-width: 210px; } .eyebrow { font-size: 12px; } .header-actions .button { min-height: 40px; font-size: 12px; padding: 6px 10px; } .connection-status { font-size: 12px; } .metrics { margin-bottom: 22px; } .metric-value { font-size: 27px; } .metric-label { font-size: 12px; } .panel-toolbar { gap: 8px; } .tab { padding: 7px 9px; font-size: 12px; gap: 5px; } .tab .icon { width: 12px; height: 12px; } .follow-control { font-size: 12px; gap: 4px; } .filters { flex-wrap: wrap; gap: 7px; } .search-field { flex-basis: 100%; } .select-field { flex: 1; } .select-field select { font-size: 12px; } .content-grid { gap: 20px; } .page-footer { flex-direction: column; gap: 4px; } .event-time { font-size: 12px; } .activity-row { gap: 8px; padding: 12px 8px; } .inspector { padding: 2px; } }
360
+ @media (max-width: 640px) { .app-shell { grid-template-columns: minmax(0, 1fr); width: 100%; margin: 0 auto; gap: 0; min-height: 100dvh; } .sidebar { position: static; height: auto; padding: 16px 18px 8px; border: 0; border-radius: 0; overflow-y: visible; } .brand { margin: 0 0 16px; font-size: 13px; } .brand-caption { display: none; } .brand-mark { width: 28px; height: 28px; } .brand-mark img { width: 56px; height: 56px; } .sidebar-title, .agent-search, .sidebar-note { display: none; } .agent-list { flex-direction: row; overflow-x: auto; padding: 2px 2px 7px; gap: 6px; } .agent-item { width: auto; min-width: 132px; max-width: 170px; padding: 8px; flex-shrink: 0; } .agent-avatar { width: 25px; height: 25px; font-size: 12px; } .agent-name { font-size: 12px; } .agent-caption { font-size: 12px; } .workspace { padding: 17px 18px 20px; } .page-header { margin-bottom: 22px; gap: 10px; } h1 { font-size: 27px; } .page-description { font-size: 12px; max-width: 210px; } .eyebrow { font-size: 12px; } .header-actions .button { min-height: 40px; font-size: 12px; padding: 6px 10px; } .connection-status { font-size: 12px; } .metrics { margin-bottom: 22px; } .metric-value { font-size: 27px; } .metric-label { font-size: 12px; } .panel-toolbar { gap: 8px; } .tab { padding: 7px 9px; font-size: 12px; gap: 5px; } .tab .icon { width: 12px; height: 12px; } .follow-control { font-size: 12px; gap: 4px; } .filters { flex-wrap: wrap; gap: 7px; } .search-field { flex-basis: 100%; } .select-field { flex: 1; } .select-field select { font-size: 12px; } .content-grid { gap: 20px; } .page-footer { flex-direction: column; gap: 4px; } .event-time { font-size: 10px; } .activity-row { gap: 6px; padding: 6px; } .event-description { display: none; } .inspector { padding: 2px; } }
360
361
  .model-settings-summary:not([hidden]) { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 14px; padding: 18px; margin-bottom: 22px; border: 1px solid var(--border); border-radius: 20px; background: var(--surface, white); }
361
362
  .model-settings-summary h2 { margin: 0 0 6px; font-size: 13px; font-weight: 500; }
362
363
  .model-settings-summary p { margin: 0; color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }