openvisio-agent 0.25.9 → 0.25.11

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,17 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## [0.25.11] — 2026-09-21
4
+
5
+ - Explain and enforce task ownership identity: `assignee_id` is for human members, while `agent_id` is for registered agents, and task mutations cannot send both.
6
+ - Keep agent messages, task comments, titles, and descriptions within readable limits at the shared MCP bridge; watcher-owned oversized replies are shortened while full output remains in Agent Studio.
7
+ - Stop automated peer exchanges after one agent reply until a human speaks, suppress sender-less echoes of watcher comments, and ignore explicitly self-authored task status events before they start another cycle.
8
+
9
+ ## [0.25.10] — 2026-09-21
10
+
11
+ - Send `thinking`, `working`, and `typing` activity through the authenticated agent WebSocket, with the REST endpoint retained only while the socket is reconnecting.
12
+ - 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.
13
+ - Keep active cycles running through ordinary peer-agent chatter; only an analyzed redirect or explicit stand-down cancels work in that thread.
14
+ - Compact Agent Studio activity and cycle rows into single-line summaries so more history remains visible at once.
4
15
 
5
16
  ## [0.25.9] — 2026-09-21
6
17
 
package/README.md CHANGED
@@ -87,7 +87,7 @@ The backend MCP may be stateful or stateless. A successful initialize response w
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. 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.
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. Explicit self-authored task updates and 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 untagged human follow-ups remain addressed to the agent. Another BYO agent can make one concrete request after the latest human turn; after this agent answers, further peer turns stay silent until a human speaks. Routine agent chatter, reciprocal questions, and sender-less echoes of watcher-delivered task comments remain silent. 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. Agent messages and comments are kept within 1,200 characters, and task mutations expose the distinction between human `assignee_id` and agent `agent_id`. 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.25.9",
3
+ "version": "0.25.11",
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": {
@@ -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
 
@@ -29,9 +30,11 @@ const mcpHttp = readFileSync(join(root, 'src', 'mcp-http.mjs'), 'utf8')
29
30
  const opencodeConfig = readFileSync(join(root, 'src', 'opencode-config.mjs'), 'utf8')
30
31
  const codexConfig = readFileSync(join(root, 'src', 'codex-config.mjs'), 'utf8')
31
32
  const codexProxy = readFileSync(join(root, 'src', 'codex-mcp-proxy.mjs'), 'utf8')
33
+ const toolPolicy = readFileSync(join(root, 'src', 'tool-policy.mjs'), 'utf8')
32
34
  const prPush = readFileSync(join(root, 'src', 'pr-push.mjs'), 'utf8')
33
35
  const cli = readFileSync(join(root, 'bin', 'cli.mjs'), 'utf8')
34
36
  const websocket = readFileSync(join(root, 'src', 'ws.mjs'), 'utf8')
37
+ const activityReporter = readFileSync(join(root, 'src', 'activity-reporter.mjs'), 'utf8')
35
38
  const cycleQueue = readFileSync(join(root, 'src', 'cycle-queue.mjs'), 'utf8')
36
39
  const mastraHarness = readFileSync(join(root, 'src', 'mastra-harness.mjs'), 'utf8')
37
40
  const runtimeProviders = readFileSync(join(root, 'src', 'runtime-providers.mjs'), 'utf8')
@@ -57,6 +60,9 @@ const assertions = [
57
60
  ['connection recovery scans assignments without an activity-feed pass', watcher.includes('const reconcileBacklog') && watcher.includes("await handleTaskSignal('task:assigned'") && !watcher.includes('reconcileActivity')],
58
61
  ['task:assigned is handled', watcher.includes("k === 'task:assigned'")],
59
62
  ['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)')],
63
+ ['task ownership distinguishes human assignees from registered agents', toolPolicy.includes('assignee_id is only for a human user/member id') && toolPolicy.includes('agent_id is only for a registered coding agent id') && codexProxy.includes('validateAgentToolArguments')],
64
+ ['agent-authored task updates stop before ticket verification', watcher.includes("kind === 'task:updated'") && watcher.includes('taskEventSenderIsSelf(raw') && events.includes('export function taskEventSenderIsSelf')],
65
+ ['agent communication has shared readable limits', toolPolicy.includes('post_message:') && toolPolicy.includes('max: 1200') && toolPolicy.includes('compactAgentMessage') && watcher.includes('compactAgentMessage(content, { maxChars: 1200 })')],
60
66
  ['compact agent records are hydrated before organization subscription', watcher.includes("callMcpTool('get_agent', { agent_id: selfAgentId })") && watcher.includes('selfAgentId != null && selfOrganizationId == null')],
61
67
  ['task signals are verified with get_ticket', watcher.includes("callMcpTool('get_ticket'")],
62
68
  ['review and testing handoffs do not restart work', watcher.includes('taskIsAwaitingReview(task, reviewIds)') && watcher.includes('taskIsAwaitingReview(ticket, typeSets.review)')],
@@ -65,13 +71,14 @@ const assertions = [
65
71
  ['agent results are delivered independently of ticket completion', watcher.includes('agentFinal: true') && watcher.includes('pendingAgentResults')],
66
72
  ['completion delivery survives reconnect and deduplicates', watcher.includes('pendingCompletionReports: [...pendingCompletionReports]') && watcher.includes('reportedCompletions: [...reportedCompletions]') && watcher.includes('reportedCompletions.has(report.key)')],
67
73
  ['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'") && watcher.includes("{ parent_id: Number(parentId) }") && watcher.includes("{ text: content }")],
74
+ ['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("{ content: message") && watcher.includes("{ text: message }")],
69
75
  ['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
76
  ['ticket comments cannot masquerade as channel completion', events.includes("didChannelMessage: completed && mcpTool === 'post_message'") && watcher.includes('result.didChannelMessage')],
71
77
  ['single-watcher acquisition is atomic and fails closed', watcher.includes("openSync(lockPath, 'wx')") && watcher.includes('Could not acquire the single-watcher lock')],
72
78
  ['websocket mention delivery uses a persistent replay guard', watcher.includes('markMentionHandled(msg, cid)') && watcher.includes('recentMentionSignatures')],
73
79
  ['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'")],
74
80
  ['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')],
81
+ ['peer-agent exchanges stop after one reply until a human turn', watcher.includes("state: 'replied'") && watcher.includes("state: 'human'") && watcher.includes('peer-loop-budget') && watcher.includes('ignored-peer-loop')],
75
82
  ['pending-ticket intent is scoped to one clause', events.includes("text.split(/(?<=[.!?])") && events.includes('return tasks && pending && question')],
76
83
  ['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')],
77
84
  ['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']")],
@@ -100,7 +107,7 @@ const assertions = [
100
107
  ['work and reply cancellation targets are isolated', watcher.includes('item.control.cancelled = true') && watcher.includes('item.control.runner?.cancelCurrent')],
101
108
  ['fresh threads resolve the dedicated agent channel', watcher.includes('dedicatedChannel(Array.isArray(channels)') && watcher.includes('No dedicated agent channel available for a new thread')],
102
109
  ['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)')],
103
- ['websocket client cannot emit legacy agent_status', !websocket.includes('agent_status')],
110
+ ['agent activity prefers websocket with REST reconnect fallback', activityReporter.includes("type: 'agent_status'") && activityReporter.includes('sendFrame?.') && activityReporter.includes('agentStateRequest') && websocket.includes('return true')],
104
111
  ['frontend consumes thinking event', activityHook.includes("'channel:agent:thinking'")],
105
112
  ['frontend consumes working event', activityHook.includes("'channel:agent:working'")],
106
113
  ['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
  }
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'
4
4
  import { WORK_SESSION_TOOL, runtimeControlTools, requestWorkSession } from './runtime-control.mjs'
5
5
  import { createMcpHttpClient } from './mcp-http.mjs'
6
6
  import { contextTools, contextToolNames, callContextService, validateContextArguments } from './context-tools.mjs'
7
+ import { validateAgentToolArguments, withAgentToolPolicy } from './tool-policy.mjs'
7
8
 
8
9
  export function toolWithoutCredentialInputs(tool) {
9
10
  const copy = structuredClone(tool)
@@ -17,7 +18,7 @@ export function toolWithoutCredentialInputs(tool) {
17
18
  schema.required = schema.required.filter((name) => name !== 'agent_api_key' && name !== 'agent_identifier')
18
19
  }
19
20
  }
20
- return copy
21
+ return withAgentToolPolicy(copy)
21
22
  }
22
23
 
23
24
  export function toolAllowed(name, { disabled = new Set(), allowed = null } = {}) {
@@ -146,16 +147,17 @@ export function runCodexMcpProxy() {
146
147
  }
147
148
  reply(id, { content: [{ type: 'text', text: JSON.stringify(data) }] }); return
148
149
  }
150
+ const toolArguments = validateAgentToolArguments(name, request.params?.arguments || {})
149
151
  let result
150
- try { result = await client.callTool(name, request.params?.arguments || {}) }
152
+ try { result = await client.callTool(name, toolArguments) }
151
153
  catch (error) {
152
154
  // An allowlisted coordination mutation still must never be replayed
153
155
  // after an ambiguous timeout. Retry only idempotent reads.
154
156
  if (!(allowedTools instanceof Set) || !allowedTools.has(name) || !/^(?:list_|get_|codebase_tree$)/.test(name)) throw error
155
- result = await client.callTool(name, request.params?.arguments || {})
157
+ result = await client.callTool(name, toolArguments)
156
158
  }
157
159
  if (localContext.length && ['list_message_thread', 'post_message'].includes(name) && !result?.isError) {
158
- try { await callContextService(contextEnvironment, { action: 'observe', name, args: request.params?.arguments || {}, result }, 1000) }
160
+ try { await callContextService(contextEnvironment, { action: 'observe', name, args: toolArguments, result }, 1000) }
159
161
  catch { /* optional indexing cannot fail the successful backend action */ }
160
162
  }
161
163
  reply(id, /^(?:list_agents|get_agent|get_agent_details)$/.test(name) ? agentResultWithoutCredentials(result) : result)
package/src/events.mjs CHANGED
@@ -55,6 +55,25 @@ export function taskBelongsToAgent(task, { id, identifier } = {}) {
55
55
  return !!identifier && assignedIdentifier != null && String(assignedIdentifier) === String(identifier)
56
56
  }
57
57
 
58
+ // A task's `agent_id` is its owner, not the author of a task update. Only
59
+ // explicit actor fields can prove that an incoming task event came from this
60
+ // agent. This distinction prevents an assigned agent from treating every task
61
+ // update as self-authored while still suppressing its own status mutations.
62
+ export function taskEventSenderIsSelf(payload, identity = {}) {
63
+ if (!payload || typeof payload !== 'object') return false
64
+ const envelopes = [payload, payload.data, payload.payload, payload.task, payload.data?.task, payload.payload?.task]
65
+ .filter((value) => value && typeof value === 'object' && !Array.isArray(value))
66
+ for (const source of envelopes) {
67
+ const actor = [source.actor_agent, source.actorAgent, source.updated_by_agent, source.updatedByAgent, source.changed_by_agent, source.changedByAgent, source.sender_agent, source.senderAgent, source.author_agent, source.authorAgent]
68
+ .find((value) => value && typeof value === 'object' && !Array.isArray(value))
69
+ const actorId = source.actor_agent_id ?? source.actorAgentId ?? source.updated_by_agent_id ?? source.updatedByAgentId ?? source.changed_by_agent_id ?? source.changedByAgentId ?? source.sender_agent_id ?? source.senderAgentId ?? source.author_agent_id ?? source.authorAgentId
70
+ const actorIdentifier = source.actor_agent_identifier ?? source.actorAgentIdentifier ?? source.updated_by_agent_identifier ?? source.updatedByAgentIdentifier ?? source.sender_agent_identifier ?? source.senderAgentIdentifier ?? source.author_agent_identifier ?? source.authorAgentIdentifier
71
+ if (!actor && actorId == null && actorIdentifier == null) continue
72
+ if (messageSenderIsSelf({ senderAgent: actor || { id: actorId, identifier: actorIdentifier }, sender_agent_id: actorId, sender_identifier: actorIdentifier }, identity)) return true
73
+ }
74
+ return false
75
+ }
76
+
58
77
  // Backend task ids are database plumbing. Teammates identify tickets by the
59
78
  // project-scoped slug shown on the board (for example, OVS-57). Never synthesize
60
79
  // a slug from the numeric id: if an older backend omits it, human-facing copy
@@ -415,12 +434,12 @@ export function normalizeRenderedMessageText(value) {
415
434
  export function messageSenderIsSelf(row, identity = {}) {
416
435
  if (!row || typeof row !== 'object') return false
417
436
  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')
437
+ const expanded = [row.taskSenderAgent, row.task_sender_agent, row.senderAgent, row.sender_agent, row.authorAgent, row.author_agent, row.actorAgent, row.actor_agent, row.createdByAgent, row.created_by_agent, row.agent, sender.agent].find((item) => item && typeof item === 'object')
419
438
  const senderKind = String(sender.type ?? sender.kind ?? sender.sender_type ?? row.sender_type ?? row.author_type ?? '').toLowerCase()
420
439
  const senderLooksLikeAgent = agentSenderKind(senderKind) || (!humanSenderKind(senderKind) && (sender.agent_id != null || sender.identifier != null || (sender.slug != null && !sender.email)))
421
440
  const agent = expanded || (senderLooksLikeAgent ? sender : {})
422
- const agentId = agent.id ?? agent.agent_id ?? row.agent_id ?? row.sender_agent_id ?? row.senderAgentId
423
- const identifier = String(agent.identifier ?? agent.slug ?? row.agent_identifier ?? row.sender_identifier ?? '')
441
+ const agentId = agent.id ?? agent.agent_id ?? row.agent_id ?? row.sender_agent_id ?? row.senderAgentId ?? row.task_sender_agent_id ?? row.taskSenderAgentId ?? row.author_agent_id ?? row.authorAgentId ?? row.actor_agent_id ?? row.actorAgentId ?? row.created_by_agent_id ?? row.createdByAgentId
442
+ const identifier = String(agent.identifier ?? agent.slug ?? row.agent_identifier ?? row.sender_identifier ?? row.task_sender_agent_identifier ?? row.taskSenderAgentIdentifier ?? row.author_agent_identifier ?? row.authorAgentIdentifier ?? row.actor_agent_identifier ?? row.actorAgentIdentifier ?? '')
424
443
  const name = String(agent.name ?? agent.display_name ?? (senderLooksLikeAgent ? sender.name : '') ?? '')
425
444
  const aliases = new Set([identity.identifier, identity.slug, identity.name].map((item) => String(item || '').toLowerCase()).filter(Boolean))
426
445
  const stableAliases = new Set([identity.identifier, identity.slug].map((item) => String(item || '').toLowerCase()).filter(Boolean))
@@ -501,7 +520,7 @@ function conversationMentions(value, selfAliases) {
501
520
 
502
521
  export function messageSenderIsAgent(message) {
503
522
  const m = message && typeof message === 'object' ? message : {}
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
523
+ if (m.taskSenderAgent || m.task_sender_agent || m.senderAgent || m.sender_agent || m.authorAgent || m.author_agent || m.actorAgent || m.actor_agent || m.createdByAgent || m.created_by_agent || m.agent || m.agent_id != null || m.sender_agent_id != null || m.senderAgentId != null || m.task_sender_agent_id != null || m.taskSenderAgentId != null || m.task_sender_agent_identifier != null || m.taskSenderAgentIdentifier != null || m.author_agent_id != null || m.authorAgentId != null || m.actor_agent_id != null || m.actorAgentId != null || m.created_by_agent_id != null || m.createdByAgentId != null) return true
505
524
  const sender = [m.taskSender, m.task_sender, m.sender, m.user, m.author, m.member].find((item) => item && typeof item === 'object') || {}
506
525
  if (sender.agent && typeof sender.agent === 'object') return true
507
526
  const kind = String(sender.type ?? sender.kind ?? sender.sender_type ?? m.sender_type ?? m.author_type ?? '').toLowerCase()
@@ -0,0 +1,100 @@
1
+ const TASK_MUTATION_TOOLS = new Set(['create_ticket', 'update_ticket', 'create_task', 'update_task'])
2
+
3
+ const TEXT_LIMITS = {
4
+ post_message: { fields: ['content', 'text', 'message', 'body'], max: 1200, label: 'Channel messages' },
5
+ create_task_comment: { fields: ['content', 'text', 'message', 'body'], max: 1200, label: 'Task comments' },
6
+ comment_ticket: { fields: ['content', 'text', 'message', 'body'], max: 1200, label: 'Task comments' },
7
+ create_ticket: { fields: ['description'], max: 2400, label: 'Task descriptions' },
8
+ update_ticket: { fields: ['description'], max: 2400, label: 'Task descriptions' },
9
+ create_task: { fields: ['description'], max: 2400, label: 'Task descriptions' },
10
+ update_task: { fields: ['description'], max: 2400, label: 'Task descriptions' },
11
+ }
12
+
13
+ const OWNERSHIP_GUIDANCE = 'Task ownership fields are different: assignee_id is only for a human user/member id; agent_id is only for a registered coding agent id. Never put an agent id in assignee_id. Set at most one of these fields and clear the other when changing owner type.'
14
+ const CONCISE_GUIDANCE = 'Keep this text compact and easy to scan. Lead with the result or request, omit repeated context, and avoid open-ended invitations.'
15
+
16
+ function appendDescription(value, guidance) {
17
+ const current = String(value || '').trim()
18
+ return current.includes(guidance) ? current : [current, guidance].filter(Boolean).join(' ')
19
+ }
20
+
21
+ function stringBranches(schema) {
22
+ if (!schema || typeof schema !== 'object') return []
23
+ return [schema, ...(Array.isArray(schema.anyOf) ? schema.anyOf : []), ...(Array.isArray(schema.oneOf) ? schema.oneOf : [])]
24
+ .filter((item) => item && typeof item === 'object' && (item.type === 'string' || !item.type))
25
+ }
26
+
27
+ function setStringLimit(schema, max, guidance = CONCISE_GUIDANCE) {
28
+ if (!schema || typeof schema !== 'object') return
29
+ schema.description = appendDescription(schema.description, `${guidance} Maximum ${max} characters.`)
30
+ for (const branch of stringBranches(schema)) {
31
+ if (branch.type === 'string') branch.maxLength = Math.min(Number.isFinite(branch.maxLength) ? branch.maxLength : max, max)
32
+ }
33
+ }
34
+
35
+ export function withAgentToolPolicy(tool) {
36
+ const copy = structuredClone(tool)
37
+ const name = String(copy?.name || '')
38
+ const schemas = [copy.inputSchema, copy.input_schema].filter((schema) => schema && typeof schema === 'object')
39
+
40
+ if (TASK_MUTATION_TOOLS.has(name)) {
41
+ copy.description = appendDescription(copy.description, OWNERSHIP_GUIDANCE)
42
+ for (const schema of schemas) {
43
+ const properties = schema.properties && typeof schema.properties === 'object' ? schema.properties : {}
44
+ if (properties.assignee_id) properties.assignee_id.description = appendDescription(properties.assignee_id.description, 'Human user/member id only. Never use an agent id here.')
45
+ if (properties.agent_id) properties.agent_id.description = appendDescription(properties.agent_id.description, 'Registered agent id only. Never use a human user id here.')
46
+ }
47
+ }
48
+
49
+ const limit = TEXT_LIMITS[name]
50
+ if (limit) {
51
+ copy.description = appendDescription(copy.description, `${limit.label} must be concise. Maximum ${limit.max} characters.`)
52
+ for (const schema of schemas) {
53
+ const properties = schema.properties && typeof schema.properties === 'object' ? schema.properties : {}
54
+ for (const field of limit.fields) if (properties[field]) setStringLimit(properties[field], limit.max)
55
+ if (TASK_MUTATION_TOOLS.has(name) && properties.title) setStringLimit(properties.title, 160, 'Use a short, specific title.')
56
+ }
57
+ }
58
+ return copy
59
+ }
60
+
61
+ const present = (value) => value !== undefined && value !== null && value !== ''
62
+
63
+ export function validateAgentToolArguments(name, args = {}) {
64
+ const tool = String(name || '')
65
+ const input = args && typeof args === 'object' && !Array.isArray(args) ? args : {}
66
+ if (TASK_MUTATION_TOOLS.has(tool) && present(input.assignee_id) && present(input.agent_id)) {
67
+ throw new Error(`${OWNERSHIP_GUIDANCE} This call supplied both fields; choose the intended owner and retry once.`)
68
+ }
69
+ const limit = TEXT_LIMITS[tool]
70
+ if (limit) {
71
+ for (const field of limit.fields) {
72
+ if (!present(input[field])) continue
73
+ const length = String(input[field]).length
74
+ if (length > limit.max) throw new Error(`${limit.label} must be ${limit.max} characters or fewer; ${field} was ${length}. Rewrite it as a short result, request, or decision and retry once.`)
75
+ }
76
+ }
77
+ if (TASK_MUTATION_TOOLS.has(tool) && present(input.title) && String(input.title).length > 160) {
78
+ throw new Error(`Task titles must be 160 characters or fewer; title was ${String(input.title).length}. Shorten it and retry once.`)
79
+ }
80
+ return input
81
+ }
82
+
83
+ export function compactAgentMessage(value, { maxChars = 1200 } = {}) {
84
+ const text = String(value || '').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
85
+ if (!text || text.length <= maxChars) return text
86
+ const suffix = '\n\n[Shortened for readability. Full output remains in Agent Studio.]'
87
+ const budget = Math.max(1, maxChars - suffix.length)
88
+ const candidate = text.slice(0, budget)
89
+ const minimum = Math.floor(budget * 0.65)
90
+ const stops = [candidate.lastIndexOf('\n\n'), candidate.lastIndexOf('. '), candidate.lastIndexOf('! '), candidate.lastIndexOf('? '), candidate.lastIndexOf(' ')]
91
+ const stop = stops.find((index) => index >= minimum)
92
+ return `${candidate.slice(0, stop >= minimum ? stop + (candidate[stop] === '.' || candidate[stop] === '!' || candidate[stop] === '?' ? 1 : 0) : budget).trimEnd()}${suffix}`
93
+ }
94
+
95
+ export const agentToolPolicy = Object.freeze({
96
+ ownershipGuidance: OWNERSHIP_GUIDANCE,
97
+ channelMessageMaxChars: TEXT_LIMITS.post_message.max,
98
+ taskCommentMaxChars: TEXT_LIMITS.create_task_comment.max,
99
+ taskDescriptionMaxChars: TEXT_LIMITS.create_ticket.max,
100
+ })
package/src/watch.mjs CHANGED
@@ -10,12 +10,13 @@ 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, messageSenderIsAgent, messageSenderIsSelf, normalizeRenderedMessageText, renderedAgentMessages, taskAgentId, taskAgentIdentifier, taskBelongsToAgent, taskCommentFromEvent, 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, taskEventSenderIsSelf, 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'
17
17
  import { contextToolGuide } from './context-tools.mjs'
18
18
  import { toolWithoutCredentialInputs } from './codex-mcp-proxy.mjs'
19
+ import { compactAgentMessage } from './tool-policy.mjs'
19
20
  import { repositoryHasPrPushAuthorization, configurePrPublishing } from './pr-push.mjs'
20
21
  import { createMcpHttpClient } from './mcp-http.mjs'
21
22
  import { createCycleQueue } from './cycle-queue.mjs'
@@ -85,9 +86,12 @@ const REPLY_DISCIPLINE = [
85
86
  ' • BE SURE BEFORE YOU SPEAK. Do not claim something is possible, done, or broken until you have actually verified it — call the tool, read the code, check the real state. Be willing to disagree or revise a conclusion when evidence changes. If you are unsure, investigate and distinguish a finding from an assumption.',
86
87
  ' • 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.',
87
88
  ' • 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.',
89
+ ' • 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.',
88
90
  ' • 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.',
89
91
  ' • 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.',
90
92
  ' • 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.',
93
+ ' • 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.',
94
+ ' • 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.',
91
95
  ' • WRITE PLAINLY. Prefer short sentences, commas, periods, and colons. Avoid em dashes except when reproducing quoted text.',
92
96
  ].join('\n')
93
97
 
@@ -619,7 +623,10 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
619
623
  // Activity belongs to an individual cycle. Independent ticket workers must
620
624
  // not clear or overwrite each other's status targets.
621
625
  const replyStatusTargets = new Set()
622
- const activity = dependencies.activityReporter || createActivityReporter({ backend, apiKey, identifier, log })
626
+ const activity = dependencies.activityReporter || createActivityReporter({
627
+ backend, apiKey, identifier, log,
628
+ sendFrame: (frame) => handle?.send?.(frame) === true,
629
+ })
623
630
  const sendStatus = (channelId, state) => {
624
631
  const id = cycleContext.getStore()?.cycleId
625
632
  const live = liveCycles.get(id)
@@ -1012,14 +1019,19 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1012
1019
  // reached the server, so never try another comment endpoint after an error.
1013
1020
  const name = names.has('create_task_comment') ? 'create_task_comment' : names.has('comment_ticket') ? 'comment_ticket' : ''
1014
1021
  if (!name) return { called: false, reason: 'not-advertised' }
1022
+ const message = compactAgentMessage(content, { maxChars: 1200 })
1015
1023
  const args = {
1016
1024
  project_id: projectId,
1017
1025
  ticket_id: ticketId,
1018
1026
  ...(name === 'create_task_comment'
1019
- ? { content, ...(validBackendId(parentId) ? { parent_id: Number(parentId) } : {}) }
1020
- : { text: content }),
1027
+ ? { content: message, ...(validBackendId(parentId) ? { parent_id: Number(parentId) } : {}) }
1028
+ : { text: message }),
1029
+ }
1030
+ try {
1031
+ const result = await callMcpTool(name, args)
1032
+ memory.remember({ key: ownTaskCommentKey(projectId, ticketId, message), kind: 'task-comment', state: 'rendered', summary: message, refs: { projectId: Number(projectId), ticketId: Number(ticketId), ...(validBackendId(parentId) ? { threadId: Number(parentId) } : {}) } })
1033
+ return { called: true, tool: name, result }
1021
1034
  }
1022
- try { return { called: true, tool: name, result: await callMcpTool(name, args) } }
1023
1035
  catch (e) {
1024
1036
  if (/\b(?:unknown|missing|unsupported) tool\b|\btool\b.*\bnot found\b/i.test(String(e?.message || e))) {
1025
1037
  try { await discoverMcpTools(true) } catch { /* the original error is enough */ }
@@ -1152,7 +1164,15 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1152
1164
  identity: () => ({ id: selfAgentId, identifier, slug, name: slug }),
1153
1165
  load: async (cid, root) => toolData(await callMcpTool('list_message_thread', { channel_id: Number(cid), message_id: Number(root) })),
1154
1166
  })
1155
- const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '', sourceMessageId, allowCancelled = false, isCancelled = () => false }) => {
1167
+ const peerTurnKey = (surface, ...parts) => `peer-turn:${surface}:${parts.map((value) => String(value ?? '?')).join(':')}`
1168
+ const ownTaskCommentKey = (projectId, ticketId, content) => {
1169
+ const normalized = normalizeRenderedMessageText(content)
1170
+ return `own-task-comment:${projectId}:${ticketId}:${normalized.length}:${normalized.slice(0, 120)}:${normalized.slice(-120)}`
1171
+ }
1172
+ const rememberPeerReply = (key, refs = {}) => {
1173
+ if (key) memory.remember({ key, kind: 'peer-turn', state: 'replied', summary: 'Replied once to a peer agent; waiting for a human turn.', refs })
1174
+ }
1175
+ const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '', sourceMessageId, allowCancelled = false, peerLoopKey = '', isCancelled = () => false }) => {
1156
1176
  if (stopping) throw new Error('Watcher is stopping')
1157
1177
  const deliveryKey = String(key || '')
1158
1178
  if (parentId == null && !validBackendId(channelId)) {
@@ -1160,7 +1180,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1160
1180
  if (channelId == null) throw new Error('No dedicated agent channel available for a new thread')
1161
1181
  projectId = undefined // dedicated channels may be organization-scoped
1162
1182
  }
1163
- const message = String(content || '').trim()
1183
+ const message = compactAgentMessage(content, { maxChars: 1200 })
1164
1184
  if (!deliveryKey || !validBackendId(channelId) || (parentId != null && !validBackendId(parentId)) || !message) return { posted: false, reason: 'invalid-delivery' }
1165
1185
  if (deliveredReplies.has(deliveryKey) || memory.has(deliveryKey, 'rendered')) {
1166
1186
  if (pendingReplyDeliveries.delete(deliveryKey)) persistReplay()
@@ -1183,7 +1203,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1183
1203
  // Store generated threaded replies before delivery. A transport outage must
1184
1204
  // retry the authoritative delivery check, not pay for another model turn.
1185
1205
  if (parentId != null) {
1186
- pendingReplyDeliveries.set(deliveryKey, { key, channelId, parentId, projectId, content: message, skipIfAnyAgentReply, sourceKey, sourceMessageId, allowCancelled })
1206
+ pendingReplyDeliveries.set(deliveryKey, { key, channelId, parentId, projectId, content: message, skipIfAnyAgentReply, sourceKey, sourceMessageId, allowCancelled, peerLoopKey })
1187
1207
  persistReplay()
1188
1208
  }
1189
1209
  const run = (async () => {
@@ -1196,6 +1216,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1196
1216
  deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
1197
1217
  memory.remember({ key: deliveryKey, kind: 'delivery', state: 'rendered', summary: duplicate ? message : rendered.at(-1)?.content, refs: { channelId: Number(channelId), threadId: Number(parentId) }, meta: { discoveredFromBackend: true } })
1198
1218
  if (sourceKey) memory.connect(deliveryKey, sourceKey, 'responds_to')
1219
+ rememberPeerReply(peerLoopKey, { channelId: Number(channelId), threadId: Number(parentId) })
1199
1220
  log('message delivery ' + deliveryKey + ' already rendered — skipped')
1200
1221
  return { posted: false, reason: duplicate ? 'same-content-rendered' : 'agent-reply-rendered' }
1201
1222
  }
@@ -1220,6 +1241,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1220
1241
  deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
1221
1242
  memory.remember({ key: deliveryKey, kind: 'delivery', state: 'rendered', summary: message, refs: { channelId: Number(channelId), ...(parentId != null ? { threadId: Number(parentId) } : {}), ...(Number.isFinite(Number(projectId)) ? { projectId: Number(projectId) } : {}) }, meta: { messageId: result.id ?? result.message?.id ?? null } })
1222
1243
  if (sourceKey) memory.connect(deliveryKey, sourceKey, 'responds_to')
1244
+ rememberPeerReply(peerLoopKey, { channelId: Number(channelId), ...(parentId != null ? { threadId: Number(parentId) } : {}) })
1223
1245
  return { posted: true, result }
1224
1246
  })().then((result) => {
1225
1247
  if (pendingReplyDeliveries.delete(deliveryKey)) persistReplay()
@@ -1232,10 +1254,10 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1232
1254
  // Task comments have no channel thread to inspect before delivery. Record the
1233
1255
  // delivery attempt before the mutation and use the comment id as the one-level
1234
1256
  // thread root. An ambiguous mutation error is never replayed automatically.
1235
- const postTaskCommentOnce = async ({ key, projectId, ticketId, parentId, content, sourceKey = '' }) => {
1257
+ const postTaskCommentOnce = async ({ key, projectId, ticketId, parentId, content, sourceKey = '', peerLoopKey = '' }) => {
1236
1258
  if (stopping) throw new Error('Watcher is stopping')
1237
1259
  const deliveryKey = String(key || '')
1238
- const message = String(content || '').trim()
1260
+ const message = compactAgentMessage(content, { maxChars: 1200 })
1239
1261
  if (!deliveryKey || !validBackendId(projectId) || !validBackendId(ticketId) || !validBackendId(parentId) || !message) return { posted: false, reason: 'invalid-delivery' }
1240
1262
  if (deliveredReplies.has(deliveryKey) || memory.has(deliveryKey, 'rendered')) {
1241
1263
  return { posted: false, reason: 'remembered' }
@@ -1250,7 +1272,9 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1250
1272
  throw new Error('No task-comment tool is advertised by the backend')
1251
1273
  }
1252
1274
  memory.remember({ key: deliveryKey, kind: 'delivery', state: 'rendered', summary: message, refs: { projectId: Number(projectId), ticketId: Number(ticketId), threadId: Number(parentId) }, meta: { surface: 'task-comment' } })
1275
+ memory.remember({ key: ownTaskCommentKey(projectId, ticketId, message), kind: 'task-comment', state: 'rendered', summary: message, refs: { projectId: Number(projectId), ticketId: Number(ticketId), threadId: Number(parentId) } })
1253
1276
  if (sourceKey) memory.connect(deliveryKey, sourceKey, 'responds_to')
1277
+ rememberPeerReply(peerLoopKey, { projectId: Number(projectId), ticketId: Number(ticketId), threadId: Number(parentId) })
1254
1278
  return { posted: true, result: result.result }
1255
1279
  })().finally(() => messageDeliveries.delete(deliveryKey))
1256
1280
  messageDeliveries.set(deliveryKey, run)
@@ -1259,9 +1283,9 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1259
1283
 
1260
1284
  async function publishWorkPlan(live, entries, acknowledgement = '') {
1261
1285
  const { control, delivery, taskRef } = live
1262
- 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))
1286
+ 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, 160))
1263
1287
  if (!steps.length || control.cancelled || !control.planKey || memory.has(control.planKey)) return
1264
- const introduction = String(acknowledgement || '').trim().slice(0, 1200) || (control.taskLabel ? `I'm on ${control.taskLabel}.` : "I'll take this on.")
1288
+ const introduction = String(acknowledgement || '').trim().slice(0, 320) || (control.taskLabel ? `I'm on ${control.taskLabel}.` : "I'll take this on.")
1265
1289
  const content = `${introduction}\n\n${steps.map((step, index) => `${index + 1}. ${step}`).join('\n')}`
1266
1290
  const channelId = delivery?.channelId ?? taskRef?.channelId ?? await projectStatusChannel(taskRef?.projectId)
1267
1291
  if (channelId == null || control.cancelled) return
@@ -1831,6 +1855,13 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1831
1855
  const ticketId = hinted && (hinted.id ?? hinted.task_id ?? hinted.taskId)
1832
1856
  const projectId = hinted && (hinted.project_id ?? hinted.projectId ?? raw.project_id ?? raw.projectId)
1833
1857
  if (!validBackendId(ticketId) || !validBackendId(projectId)) { log(kind + ' missing ticket/project identity — ignored'); return }
1858
+ if (kind === 'task:updated') {
1859
+ try { await ensureSelfAgent() } catch { /* stable identifier may still prove authorship */ }
1860
+ if (taskEventSenderIsSelf(raw, { id: selfAgentId, identifier, slug, name })) {
1861
+ log(kind + ' ticket #' + ticketId + ' was updated by this agent — ignored')
1862
+ return { status: 'ignored-self' }
1863
+ }
1864
+ }
1834
1865
  try {
1835
1866
  const ticketData = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
1836
1867
  const ticket = ticketData.ticket ?? ticketData.task ?? ticketData
@@ -1914,7 +1945,19 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1914
1945
  }
1915
1946
  try { await ensureSelfAgent() }
1916
1947
  catch (error) { log('agent identity lookup unavailable; using configured aliases: ' + (error?.message || error)) }
1948
+ const rootCommentId = messageParentId(comment) ?? Number(commentId)
1949
+ if (memory.has(ownTaskCommentKey(projectId, ticketId, text), 'rendered')) {
1950
+ log(`task:comment project=${projectId} ticket=${ticketId} matches this agent's delivered comment — ignored`)
1951
+ return { status: 'ignored-self' }
1952
+ }
1917
1953
  if (messageSenderIsSelf(comment, { id: selfAgentId, identifier, slug, name })) return { status: 'ignored-self' }
1954
+ const peer = messageSenderIsAgent(comment)
1955
+ const loopKey = peerTurnKey('task-comment', projectId, ticketId, rootCommentId)
1956
+ if (peer && memory.has(loopKey, 'replied')) {
1957
+ log(`task:comment project=${projectId} ticket=${ticketId} ignored after one peer-agent reply; waiting for a human turn`)
1958
+ return { status: 'ignored-peer-loop' }
1959
+ }
1960
+ if (!peer) memory.remember({ key: loopKey, kind: 'peer-turn', state: 'human', summary: text, refs: { projectId: Number(projectId), ticketId: Number(ticketId), threadId: Number(rootCommentId) } })
1918
1961
  const target = classifyConversationTarget(comment, [...selfAliases], { threadOwned: true })
1919
1962
  if (target.action !== 'handle') {
1920
1963
  log('task:comment ignored before model start (' + target.reason + ')')
@@ -1936,9 +1979,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1936
1979
  log(`task:comment ticket #${ticketId} is not assigned to this agent — ignored`)
1937
1980
  return { status: 'ignored-unassigned' }
1938
1981
  }
1939
- const rootCommentId = messageParentId(comment) ?? Number(commentId)
1940
1982
  const who = senderName(comment)
1941
- const peer = messageSenderIsAgent(comment)
1942
1983
  const ticketSlug = ticketDisplaySlug(ticket)
1943
1984
  memory.remember({ key: sourceKey, kind: 'task-comment', state: 'received', summary: text, refs: { projectId: Number(projectId), ticketId: Number(ticketId), messageId: Number(commentId), threadId: Number(rootCommentId) } })
1944
1985
  const delivery = {
@@ -1949,6 +1990,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1949
1990
  parentId: Number(rootCommentId),
1950
1991
  sourceKey,
1951
1992
  watcherOwned: true,
1993
+ peerLoopKey: peer ? loopKey : '',
1952
1994
  }
1953
1995
  const label = ticketSlug || String(ticket.title || 'the assigned ticket')
1954
1996
  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.' : ''}`
@@ -2071,13 +2113,19 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
2071
2113
  if (!threadOwned || memory.has(sourceKey, 'received')) { log('agent:mention (dup) — skipped'); return }
2072
2114
  log('agent:mention replay recovered for active owned thread')
2073
2115
  }
2116
+ const loopKey = cid != null && threadRoot != null ? peerTurnKey('channel', cid, threadRoot) : ''
2117
+ if (peerMessage && loopKey && memory.has(loopKey, 'replied')) {
2118
+ log('agent:mention ignored before model start (peer-loop-budget)')
2119
+ return { status: 'ignored-peer-loop' }
2120
+ }
2121
+ if (!peerMessage && loopKey) memory.remember({ key: loopKey, kind: 'peer-turn', state: 'human', summary: text, refs: { channelId: Number(cid), threadId: Number(threadRoot) } })
2074
2122
  const target = classifyConversationTarget(msg, [...selfAliases], { threadOwned })
2075
2123
  if (target.action === 'ignore') {
2076
2124
  log('agent:mention ignored before model start (' + target.reason + ')')
2077
- // A redirect or another agent taking over invalidates any older work or
2078
- // draft still queued for this thread. The event name alone cannot retain
2079
- // ownership after the actual message says otherwise.
2080
- if (cid != null && threadRoot != null && (target.reason === 'explicit-other-recipient' || target.reason === 'other-agent-chatter' || target.reason === 'redirected-to-later-agent')) {
2125
+ // Ordinary peer chatter is additional thread context, not cancellation.
2126
+ // Only a message whose recipient analysis shows an actual handoff may
2127
+ // invalidate older work; explicit stand-down is handled below.
2128
+ if (cid != null && threadRoot != null && (target.reason === 'explicit-other-recipient' || target.reason === 'redirected-to-later-agent')) {
2081
2129
  cancelThread(cid, threadRoot, text || target.reason)
2082
2130
  }
2083
2131
  return
@@ -2099,7 +2147,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
2099
2147
  // redirect can cancel its process. The watcher delivers the final answer
2100
2148
  // for every runtime after an authoritative last-moment thread check.
2101
2149
  const conversationDelivery = (stage = 'reply', skipIfAnyAgentReply = stage !== 'result', allowChannelMessaging = false) => cid != null
2102
- ? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey, sourceMessageId: mid, watcherOwned: true, allowChannelMessaging }
2150
+ ? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey, sourceMessageId: mid, watcherOwned: true, allowChannelMessaging, peerLoopKey: peerMessage ? loopKey : '' }
2103
2151
  : null
2104
2152
  // Under-the-hood model control from chat (view / switch the model the agent runs).
2105
2153
  const mcmd = cid != null ? parseModelCmd(text) : null
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; }