openvisio-agent 0.25.6 → 0.25.8
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 +13 -0
- package/README.md +4 -4
- package/USER_GUIDE.md +1 -1
- package/package.json +1 -1
- package/scenarios/workspace.scenarios.mjs +4 -3
- package/scripts/certify.mjs +5 -3
- package/src/events.mjs +13 -0
- package/src/watch.mjs +123 -83
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## [0.25.8] — 2026-09-21
|
|
6
|
+
|
|
7
|
+
- Hydrate compact `list_agents` identities through `get_agent` so watchers subscribe to their verified organization and receive mentions from every accessible channel.
|
|
8
|
+
- Route organization `task:created` and `task:updated` events through the same authoritative assignment verification used by targeted assignment events.
|
|
9
|
+
- Allow explicit requests to publish in another named channel while retaining guarded, single-delivery replies in the source thread and dedicated-channel defaults for unsolicited results.
|
|
10
|
+
- Start organization discovery immediately after each WebSocket connection and retain event-driven idle behavior.
|
|
11
|
+
|
|
12
|
+
## [0.25.7] — 2026-09-21
|
|
13
|
+
|
|
14
|
+
- Restore recovery for addressed messages that the backend omits from the watcher WebSocket stream.
|
|
15
|
+
- Cache projects during connection recovery, then check only `list_activity` every 30 seconds while idle. The lightweight check does not rescan projects, tasks, boards, or task types and starts no model provider unless it finds a new addressed message.
|
|
16
|
+
- Add a behavioral regression test proving a missed mention is handled once without idle project or task scans.
|
|
17
|
+
|
|
5
18
|
## [0.25.6] — 2026-09-21
|
|
6
19
|
|
|
7
20
|
- Keep healthy backend watchers event-driven after their connection recovery pass instead of polling every project for tasks and activity every 30 seconds.
|
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
|
|
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.
|
|
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 subscribes only to the organization verified from
|
|
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.
|
|
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
|
|
|
@@ -112,9 +112,9 @@ The helper `openvisio-agent push-pr-branch` accepts no arguments. It pushes only
|
|
|
112
112
|
|
|
113
113
|
Authorization recovery checks paused repositories locally every two seconds and clears both policy and failed-revision state before live ticket verification. It does not need a new watcher or a new chat message. Updating the package still requires the running service to load the new version once.
|
|
114
114
|
|
|
115
|
-
Fresh top-level
|
|
115
|
+
Responses to existing requests stay in their source thread. Fresh top-level results use the agent’s dedicated channel by default; when a teammate explicitly requests a message in another named channel, the agent resolves that destination from live `list_channels` and posts there. The watcher resolves a unique exact identity/name match (including `agent-alex` and `alex-agent`) and never silently falls back to general. Use `connect ... --channel <id>` for a differently named dedicated channel. If a requested or default channel is missing or ambiguous, the agent reports that condition instead of inventing a channel or id.
|
|
116
116
|
|
|
117
|
-
Codex, OpenCode, Gemini CLI, and Qwen Code use Mastra ACP directly. OpenCode starts only for an accepted cycle and exits as soon as that cycle releases it, so the idle watcher does not retain an OpenCode child process. Other runtimes retain work sessions per ticket in a six-entry pool and reply sessions in an eight-entry pool; their idle sessions expire after five minutes, and active sessions cannot be evicted. Git snapshots are asynchronous, lightweight commands lazily load the watcher, and logs separate queue wait, preflight, first-event, and total runtime time. Studio reads watcher liveness from the local PID lock instead of requiring journal heartbeats, and model changes arrive through filesystem notifications. Projects reconcile with concurrency three and enqueue tickets before reading optional activity history. Coordination sessions can update the board without gaining repository access.
|
|
117
|
+
Codex, OpenCode, Gemini CLI, and Qwen Code use Mastra ACP directly. OpenCode starts only for an accepted cycle and exits as soon as that cycle releases it, so the idle watcher does not retain an OpenCode child process. Other runtimes retain work sessions per ticket in a six-entry pool and reply sessions in an eight-entry pool; their idle sessions expire after five minutes, and active sessions cannot be evicted. Git snapshots are asynchronous, lightweight commands lazily load the watcher, and logs separate queue wait, preflight, first-event, and total runtime time. Studio reads watcher liveness from the local PID lock instead of requiring journal heartbeats, and model changes arrive through filesystem notifications. Projects reconcile with concurrency three and enqueue tickets before reading optional activity history; later idle recovery reads activity only. Coordination sessions can update the board without gaining repository access.
|
|
118
118
|
|
|
119
119
|
Assignments use independent FIFO entries with stable-key duplicate suppression and bounded, worktree-aware concurrency. Backlog recovery uses the same ticket verification path as WebSocket events, and each queued ticket is checked again before a model starts. MCP calls have a 20-second deadline including response bodies; tool discovery is cached and paginated. Codex MCP failures retain a bounded, credential-redacted reason in the watcher log. Cancellation targets only its ACP session. Credentials and replay state use atomic file replacement; Mastra persists contextual memory in libSQL.
|
|
120
120
|
|
package/USER_GUIDE.md
CHANGED
|
@@ -74,7 +74,7 @@ Stopping a watcher interrupts its active work. When you are ready to resume, run
|
|
|
74
74
|
- **Assign repository work:** assign an actionable ticket to the agent in a project with a linked codebase. Include the problem, expected behavior, and how the result should be checked.
|
|
75
75
|
- **Continue a conversation:** reply in the same thread. Keep follow-up instructions concrete, such as “The new test fails on an empty email address; please fix that case.”
|
|
76
76
|
|
|
77
|
-
Replies stay in their source thread. New completion messages use the agent’s dedicated channel. Check the ticket and any linked pull request for delivered results. A progress message describes ongoing work; wait for the final result or blocker.
|
|
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
79
|
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
80
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openvisio-agent",
|
|
3
|
-
"version": "0.25.
|
|
3
|
+
"version": "0.25.8",
|
|
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": {
|
|
@@ -16,18 +16,19 @@ async function eventually(predicate, description) {
|
|
|
16
16
|
assert.fail(`Watcher did not reach: ${description}`)
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
export function fixture({ agent = 'codex', name, task = true, run, threadError = false, commentTools = [], onComment, selfProfile = self, listAgents, getTicket, listChannels, transportRecoveryDelays, activityReporter } = {}) {
|
|
19
|
+
export function fixture({ agent = 'codex', name, task = true, run, threadError = false, commentTools = [], onComment, selfProfile = self, listAgents, getAgent, getTicket, listChannels, listActivity, transportRecoveryDelays, activityReporter } = {}) {
|
|
20
20
|
const dir = mkdtempSync(join(tmpdir(), 'byo-workspace-'))
|
|
21
21
|
const posts = [], runs = [], calls = [], logs = [], messages = [], subscriptions = []
|
|
22
22
|
const ticket = { id: 77, project_id: 1, slug: 'OPEN-77', title: 'Repair the component', agent_id: 7, status: 'In Progress', type_id: 1, updated_at: 'revision-1' }
|
|
23
23
|
let watcher, connection, messageId = 100, closed = 0
|
|
24
24
|
const memory = createByoMemoryGraph({ path: join(dir, 'ledger.json') })
|
|
25
25
|
const mcp = {
|
|
26
|
-
listTools: async () => ['list_agents', 'list_projects', 'list_boards', 'list_tasks', 'get_ticket', 'update_ticket', 'post_message', 'list_message_thread', 'list_channels', 'list_task_types', 'list_activity', ...commentTools].map((name) => ({ name, ...(name === 'list_task_types' ? { inputSchema: { required: ['project_id', 'board_id'], properties: { project_id: {}, board_id: {} } } } : {}) })),
|
|
26
|
+
listTools: async () => ['list_agents', 'get_agent', 'list_projects', 'list_boards', 'list_tasks', 'get_ticket', 'update_ticket', 'post_message', 'list_message_thread', 'list_channels', 'list_task_types', 'list_activity', ...commentTools].map((name) => ({ name, ...(name === 'list_task_types' ? { inputSchema: { required: ['project_id', 'board_id'], properties: { project_id: {}, board_id: {} } } } : {}) })),
|
|
27
27
|
callTool: async (name, args = {}) => {
|
|
28
28
|
calls.push({ name, args })
|
|
29
29
|
if (commentTools.includes(name)) return onComment ? await onComment(name, args) : { id: 500 }
|
|
30
30
|
if (name === 'list_agents') return listAgents ? await listAgents() : { agents: [selfProfile] }
|
|
31
|
+
if (name === 'get_agent') { assert.equal(args.agent_id, Number(selfProfile.id)); return { agent: getAgent ? await getAgent(args.agent_id) : selfProfile } }
|
|
31
32
|
if (name === 'list_projects') return { projects: [{ id: 1, name: 'Workspace' }] }
|
|
32
33
|
if (name === 'list_tasks') return { tasks: task ? [{ ...ticket }] : [] }
|
|
33
34
|
if (name === 'list_boards') return { boards: [{ id: 4 }] }
|
|
@@ -36,7 +37,7 @@ export function fixture({ agent = 'codex', name, task = true, run, threadError =
|
|
|
36
37
|
if (name === 'update_ticket') { Object.assign(ticket, args); return { ticket: { ...ticket } } }
|
|
37
38
|
if (name === 'list_channels' && listChannels) return { channels: await listChannels() }
|
|
38
39
|
if (name === 'list_channels') return { channels: [{ id: 2, name: 'alex', agent_id: 7 }, { id: 9, name: 'general' }] }
|
|
39
|
-
if (name === 'list_activity') return { activities: [] }
|
|
40
|
+
if (name === 'list_activity') return listActivity ? await listActivity(args) : { activities: [] }
|
|
40
41
|
if (name === 'list_message_thread') {
|
|
41
42
|
if (typeof threadError === 'function') await threadError()
|
|
42
43
|
else if (threadError) throw new Error('Thread service unavailable')
|
package/scripts/certify.mjs
CHANGED
|
@@ -52,10 +52,12 @@ const assertions = [
|
|
|
52
52
|
['new follow-ups only deduplicate later replies', watcher.includes('repliesAfterSource(renderedAgentMessages') && watcher.includes('sourceMessageId: mid')],
|
|
53
53
|
['Mastra snapshots do not block the event loop', mastraHarness.includes('await execFileAsync') && !mastraHarness.includes('spawnSync')],
|
|
54
54
|
['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
|
|
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')],
|
|
56
56
|
['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(
|
|
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'))],
|
|
58
58
|
['task:assigned is handled', watcher.includes("k === 'task:assigned'")],
|
|
59
|
+
['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
|
+
['compact agent records are hydrated before organization subscription', watcher.includes("callMcpTool('get_agent', { agent_id: selfAgentId })") && watcher.includes('selfAgentId != null && selfOrganizationId == null')],
|
|
59
61
|
['task signals are verified with get_ticket', watcher.includes("callMcpTool('get_ticket'")],
|
|
60
62
|
['review and testing handoffs do not restart work', watcher.includes('taskIsAwaitingReview(task, reviewIds)') && watcher.includes('taskIsAwaitingReview(ticket, typeSets.review)')],
|
|
61
63
|
['review handoff releases the task key for future rework', watcher.includes('seenTasks.delete(taskKey)') && watcher.includes('seenTasks.delete(key)')],
|
|
@@ -79,7 +81,7 @@ const assertions = [
|
|
|
79
81
|
['generic coding pickup messages are absent', !watcher.includes("I've picked this up and will return here with the verified result")],
|
|
80
82
|
['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}')],
|
|
81
83
|
['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')],
|
|
82
|
-
['
|
|
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 = [')],
|
|
83
85
|
['ACP sessions receive the authenticated OpenVisio proxy', mastraHarness.includes("name: 'openvisio-team-watcher'") && mastraHarness.includes('OPENVISIO_CODEX_MCP_URL')],
|
|
84
86
|
['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')],
|
|
85
87
|
['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')],
|
package/src/events.mjs
CHANGED
|
@@ -565,6 +565,19 @@ export function conversationAsksPendingTickets(value) {
|
|
|
565
565
|
return pending && question
|
|
566
566
|
}
|
|
567
567
|
|
|
568
|
+
// A guarded reply normally has post_message removed so the runtime cannot race
|
|
569
|
+
// the watcher's single source-thread delivery. Keep that guard unless the human
|
|
570
|
+
// explicitly asks the agent to publish into a named channel. The destination is
|
|
571
|
+
// still resolved from live list_channels by the runtime; this only identifies
|
|
572
|
+
// the narrow class of requests that needs the posting tool.
|
|
573
|
+
export function conversationRequestsChannelMessage(value) {
|
|
574
|
+
const text = String(value || '')
|
|
575
|
+
const action = /\b(?:send|post|message|announce|notify|share|write)\b/i.test(text)
|
|
576
|
+
const channel = /#[a-z0-9][a-z0-9_-]*\b/i.test(text) || /\b(?:in|into|on|to)\s+(?:the\s+)?(?:channel\s+)?[a-z0-9][a-z0-9 _-]{0,80}\s+channel\b/i.test(text) || /\bchannel\s+(?:named\s+|called\s+)?["'`#]?[a-z0-9][a-z0-9 _-]*/i.test(text)
|
|
577
|
+
const negated = /\b(?:do\s+not|don't|dont|never)\s+(?:send|post|message|announce|notify|share|write)\b/i.test(text)
|
|
578
|
+
return action && channel && !negated
|
|
579
|
+
}
|
|
580
|
+
|
|
568
581
|
// Filing a ticket is a team mutation, even when its description asks for code.
|
|
569
582
|
// Questions about a previous creation and negated requests stay read-only.
|
|
570
583
|
export function conversationCreatesTicket(value) {
|
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, 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, messageSenderIsSelf, normalizeRenderedMessageText, renderedAgentMessages, taskAgentId, taskAgentIdentifier, taskBelongsToAgent, 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'
|
|
@@ -87,7 +87,7 @@ const REPLY_DISCIPLINE = [
|
|
|
87
87
|
' • 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.',
|
|
88
88
|
' • 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
89
|
' • 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
|
-
' •
|
|
90
|
+
' • 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.',
|
|
91
91
|
' • WRITE PLAINLY. Prefer short sentences, commas, periods, and colons. Avoid em dashes except when reproducing quoted text.',
|
|
92
92
|
].join('\n')
|
|
93
93
|
|
|
@@ -898,6 +898,8 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
898
898
|
// hoping poll_inbox re-surfaces the same item. Each queue entry retains that
|
|
899
899
|
// source independently through work, continuation, and delivery.
|
|
900
900
|
let backlogProbeBusy = false
|
|
901
|
+
let activityProbeBusy = false
|
|
902
|
+
let knownProjects = []
|
|
901
903
|
let selfAgentId = null
|
|
902
904
|
let selfOrganizationId = null
|
|
903
905
|
let identityContext = memory.get?.(`identity:${identifier}`)?.meta?.context || agentProfileContext({}, identifier)
|
|
@@ -942,15 +944,27 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
942
944
|
let selfRead = null
|
|
943
945
|
let selfReadAfter = 0
|
|
944
946
|
const ensureSelfAgent = async () => {
|
|
945
|
-
if (selfAgentId != null) return
|
|
947
|
+
if (selfAgentId != null && selfOrganizationId != null) return
|
|
946
948
|
if (!selfRead && Date.now() < selfReadAfter) return
|
|
947
|
-
if (!selfRead) selfRead =
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
949
|
+
if (!selfRead) selfRead = (async () => {
|
|
950
|
+
if (selfAgentId == null) {
|
|
951
|
+
const data = toolData(await callMcpTool('list_agents'), 'list_agents')
|
|
952
|
+
const agents = Array.isArray(data.agents) ? data.agents : []
|
|
953
|
+
const identified = agents.find((row) => String(row?.identifier || row?.slug || '') === identifier)
|
|
954
|
+
const named = agents.filter((row) => !row?.identifier && !row?.slug && String(row?.name || '').trim().toLowerCase() === String(name || '').trim().toLowerCase())
|
|
955
|
+
rememberSelfAgent(identified || (named.length === 1 ? named[0] : null))
|
|
956
|
+
}
|
|
957
|
+
// Compact list_agents responses can contain only id + name. Hydrate that
|
|
958
|
+
// authenticated match so organization fanout can be subscribed without
|
|
959
|
+
// trusting organization data from an incoming event.
|
|
960
|
+
if (selfAgentId != null && selfOrganizationId == null) {
|
|
961
|
+
const names = await discoverMcpTools()
|
|
962
|
+
if (names.has('get_agent')) {
|
|
963
|
+
const detail = toolData(await callMcpTool('get_agent', { agent_id: selfAgentId }), 'get_agent')
|
|
964
|
+
rememberSelfAgent(detail.agent ?? detail.data?.agent ?? detail)
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
})().catch((error) => {
|
|
954
968
|
log('Agent profile unavailable; continuing with task ownership and configured aliases: ' + (error?.message || error))
|
|
955
969
|
}).finally(() => { selfRead = null; selfReadAfter = Date.now() + 60_000 })
|
|
956
970
|
await selfRead
|
|
@@ -1130,7 +1144,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1130
1144
|
const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '', sourceMessageId, allowCancelled = false, isCancelled = () => false }) => {
|
|
1131
1145
|
if (stopping) throw new Error('Watcher is stopping')
|
|
1132
1146
|
const deliveryKey = String(key || '')
|
|
1133
|
-
if (parentId == null) {
|
|
1147
|
+
if (parentId == null && !validBackendId(channelId)) {
|
|
1134
1148
|
channelId = await projectStatusChannel(projectId)
|
|
1135
1149
|
if (channelId == null) throw new Error('No dedicated agent channel available for a new thread')
|
|
1136
1150
|
projectId = undefined // dedicated channels may be organization-scoped
|
|
@@ -1424,6 +1438,87 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1424
1438
|
return publishBlocker({ prompt, taskRef, delivery, notice, ticketNotice, pause: true })
|
|
1425
1439
|
}
|
|
1426
1440
|
|
|
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
|
+
|
|
1427
1522
|
// Reconcile everything that may have arrived while disconnected. This spends no
|
|
1428
1523
|
// model tokens unless the tools actually report pending work.
|
|
1429
1524
|
const reconcileBacklog = async () => {
|
|
@@ -1433,8 +1528,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1433
1528
|
try {
|
|
1434
1529
|
const projectsData = toolData(await callMcpTool('list_projects'), 'list_projects')
|
|
1435
1530
|
const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
|
|
1436
|
-
|
|
1437
|
-
let activityReplayTouched = false
|
|
1531
|
+
knownProjects = projects.map((project) => ({ id: project.id, name: project.name }))
|
|
1438
1532
|
await mapConcurrent(projects, 3, async (project) => {
|
|
1439
1533
|
try {
|
|
1440
1534
|
const assigned = []
|
|
@@ -1484,71 +1578,9 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1484
1578
|
seenTasks.delete(key)
|
|
1485
1579
|
await handleTaskSignal('task:assigned', { task: { id: next.id, project_id: next.projectId } })
|
|
1486
1580
|
}
|
|
1487
|
-
const activityData = await callMcpTool('list_activity', { project_id: project.id }).then(toolData).catch((error) => {
|
|
1488
|
-
log('activity reconciliation unavailable: ' + (error?.message || error))
|
|
1489
|
-
return {}
|
|
1490
|
-
})
|
|
1491
|
-
const activities = Array.isArray(activityData.activities) ? activityData.activities : Array.isArray(activityData.activity) ? activityData.activity : []
|
|
1492
|
-
await ensureSelfAgent()
|
|
1493
|
-
const mentionNeedles = [...selfAliases].map((alias) => '@' + alias)
|
|
1494
|
-
for (const item of activities) {
|
|
1495
|
-
const text = JSON.stringify(item)
|
|
1496
|
-
const activityKey = String(project.id) + ':' + String(item.id ?? item.message_id ?? item.messageId ?? text.slice(0, 500))
|
|
1497
|
-
const data = item?.data && typeof item.data === 'object' ? item.data : null
|
|
1498
|
-
const activityMessage = item?.message && typeof item.message === 'object'
|
|
1499
|
-
? item.message
|
|
1500
|
-
: data?.message && typeof data.message === 'object' ? data.message : item
|
|
1501
|
-
const activityText = String(activityMessage?.content ?? activityMessage?.body ?? activityMessage?.text ?? '').toLowerCase()
|
|
1502
|
-
const activityChannelId = item?.channel_id ?? item?.channelId ?? data?.channel_id ?? data?.channelId
|
|
1503
|
-
const activityMessageId = activityMessage?.id ?? activityMessage?.message_id ?? activityMessage?.messageId
|
|
1504
|
-
const activityParentId = messageParentId(activityMessage)
|
|
1505
|
-
const activityThreadRoot = activityParentId != null ? activityParentId : activityMessageId
|
|
1506
|
-
const activityControlKey = threadControlKey(activityChannelId, activityThreadRoot)
|
|
1507
|
-
const ownedActivity = !!activityControlKey && memory.has(activityControlKey, 'active')
|
|
1508
|
-
// Search only the source message, never the whole activity envelope: an
|
|
1509
|
-
// old tagged thread root can otherwise make another agent's new reply
|
|
1510
|
-
// look addressed to this watcher. Explicit mentions and human follow-ups
|
|
1511
|
-
// in a persistently owned thread both enter the guarded per-message path.
|
|
1512
|
-
const addressedActivity = mentionNeedles.some((needle) => activityText.includes(needle)) || ownedActivity || activityParentId != null
|
|
1513
|
-
if (!seenActivities.has(activityKey) && addressedActivity && /message|mention|channel/i.test(text)) {
|
|
1514
|
-
seenActivities.add(activityKey); trimSeen(seenActivities); activityReplayTouched = true
|
|
1515
|
-
// The same logical mention may already have arrived over WebSocket.
|
|
1516
|
-
// Share the id/signature guard instead of starting a second model turn.
|
|
1517
|
-
// Recover the former pre-routing acknowledgement bug exactly once
|
|
1518
|
-
// when an owned-thread source has no received ledger entry.
|
|
1519
|
-
if (markMentionHandled(activityMessage, activityChannelId)) {
|
|
1520
|
-
const activityMentionKeys = mentionDedupeKeys(activityMessage, activityChannelId)
|
|
1521
|
-
const activitySourceKey = `mention:${activityChannelId ?? '?'}:${activityMentionKeys.idKey || activityMentionKeys.signatureKey || activityThreadRoot}`
|
|
1522
|
-
if (memory.has(activitySourceKey, 'received')) continue
|
|
1523
|
-
}
|
|
1524
|
-
mentionActivity.push({
|
|
1525
|
-
projectId: project.id,
|
|
1526
|
-
project: project.name,
|
|
1527
|
-
channelId: activityChannelId,
|
|
1528
|
-
message: activityMessage,
|
|
1529
|
-
activity: item, activityKey,
|
|
1530
|
-
})
|
|
1531
|
-
}
|
|
1532
|
-
}
|
|
1533
1581
|
} catch (error) { log('Project reconciliation failed: ' + (error?.message || error)) }
|
|
1534
1582
|
})
|
|
1535
|
-
|
|
1536
|
-
if (mentionActivity.length) {
|
|
1537
|
-
log('backlog reconciliation found ' + mentionActivity.length + ' mention(s) -> guarded reply cycle(s)')
|
|
1538
|
-
// Replay each real message through the exact same delivery path as a live
|
|
1539
|
-
// WebSocket mention. This preserves thread ids and lets postMessageOnce
|
|
1540
|
-
// consult the rendered thread before any reply is emitted.
|
|
1541
|
-
for (const mention of mentionActivity) {
|
|
1542
|
-
void onEvent('agent:mention', {
|
|
1543
|
-
project_id: mention.projectId,
|
|
1544
|
-
channel_id: mention.channelId,
|
|
1545
|
-
message: mention.message,
|
|
1546
|
-
_mentionAlreadyMarked: true,
|
|
1547
|
-
})?.then((result) => {
|
|
1548
|
-
if (result?.status === 'retry') { seenActivities.delete(mention.activityKey); persistReplay() }
|
|
1549
|
-
})
|
|
1550
|
-
}
|
|
1551
|
-
}
|
|
1583
|
+
await replayRecentActivity(projects)
|
|
1552
1584
|
} catch (e) {
|
|
1553
1585
|
log('backlog reconciliation failed: ' + (e && e.message ? e.message : e))
|
|
1554
1586
|
} finally { backlogProbeBusy = false }
|
|
@@ -1640,7 +1672,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1640
1672
|
const ticketWorktree = preparedWorkdir || (kind === 'full' && activeTaskRef ? findTicketWorktree(workdir, activeTaskRef.ticketId) : '')
|
|
1641
1673
|
const runnerOptions = {
|
|
1642
1674
|
cycleId: cycleControl.cycleId, attempt: cycleControl.attempt || 1,
|
|
1643
|
-
...(delivery?.watcherOwned ? { disabledMcpTools: ['post_message'] } : {}),
|
|
1675
|
+
...(delivery?.watcherOwned && !delivery?.allowChannelMessaging ? { disabledMcpTools: ['post_message'] } : {}),
|
|
1644
1676
|
...(ticketWorktree ? { workdir: ticketWorktree } : {}),
|
|
1645
1677
|
}
|
|
1646
1678
|
const targets = [...new Set(targetChannels.filter((id) => id != null && Number.isFinite(Number(id))).map(Number))]
|
|
@@ -1982,7 +2014,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1982
2014
|
|
|
1983
2015
|
function routeEvent(k, d) {
|
|
1984
2016
|
const raw = d && typeof d === 'object' ? d : {}
|
|
1985
|
-
if (k === 'task:assigned' || k === 'task:updated') {
|
|
2017
|
+
if (k === 'task:created' || k === 'task:assigned' || k === 'task:updated') {
|
|
1986
2018
|
return handleTaskSignal(k, raw)
|
|
1987
2019
|
} else if (k === 'agent:mention') {
|
|
1988
2020
|
const cid = raw.channel_id != null ? raw.channel_id : (raw.channelId != null ? raw.channelId : null)
|
|
@@ -2035,8 +2067,8 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2035
2067
|
// Every runtime carries the source thread through the queue so a later
|
|
2036
2068
|
// redirect can cancel its process. The watcher delivers the final answer
|
|
2037
2069
|
// for every runtime after an authoritative last-moment thread check.
|
|
2038
|
-
const conversationDelivery = (stage = 'reply', skipIfAnyAgentReply = stage !== 'result') => cid != null
|
|
2039
|
-
? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey, sourceMessageId: mid, watcherOwned: true }
|
|
2070
|
+
const conversationDelivery = (stage = 'reply', skipIfAnyAgentReply = stage !== 'result', allowChannelMessaging = false) => cid != null
|
|
2071
|
+
? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey, sourceMessageId: mid, watcherOwned: true, allowChannelMessaging }
|
|
2040
2072
|
: null
|
|
2041
2073
|
// Under-the-hood model control from chat (view / switch the model the agent runs).
|
|
2042
2074
|
const mcmd = cid != null ? parseModelCmd(text) : null
|
|
@@ -2066,6 +2098,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2066
2098
|
return
|
|
2067
2099
|
}
|
|
2068
2100
|
const creatingTicket = conversationCreatesTicket(text)
|
|
2101
|
+
const channelMessaging = !creatingTicket && conversationRequestsChannelMessage(text)
|
|
2069
2102
|
const assignmentKey = controlKey ? `assignment-routing:${controlKey}` : ''
|
|
2070
2103
|
const priorAssignment = assignmentKey ? memory.get(assignmentKey) : null
|
|
2071
2104
|
const retryAssignment = priorAssignment?.state === 'failed' && /^\s*(?:please\s+)?(?:try\s+again|retry)(?:\s+please)?[.!?]*\s*$/i.test(text)
|
|
@@ -2101,10 +2134,10 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2101
2134
|
// subsequent working/typing heartbeat for its lane.
|
|
2102
2135
|
if (cid != null) sendStatus(cid, 'thinking')
|
|
2103
2136
|
const codingMention = !creatingTicket && canCode && conversationNeedsCode(text, { rootContent: raw._threadRootContent })
|
|
2104
|
-
const replyDelivery = conversationDelivery(codingMention ? 'result' : 'reply', !codingMention)
|
|
2137
|
+
const replyDelivery = conversationDelivery(codingMention ? 'result' : 'reply', !codingMention, channelMessaging)
|
|
2105
2138
|
let ctx = cid != null
|
|
2106
2139
|
? replyDelivery?.watcherOwned
|
|
2107
|
-
? `The watcher verified this source message is addressed to you in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". ${codingMention ? 'Complete the concrete repository work and verification first.' : 'Answer the request directly.'} Do NOT call post_message; it is intentionally unavailable. Return only the final reply as your final answer. The watcher will re-check the live thread and render it at most once.${who ? ` To mention the requester, use their exact full name "@${who}".` : ''} Use additional discovery, resources, and history when they help answer this request.`
|
|
2140
|
+
? `The watcher verified this source message is addressed to you in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". ${codingMention ? 'Complete the concrete repository work and verification first.' : 'Answer the request directly.'} ${channelMessaging ? 'The teammate explicitly requested a message in another channel. Use list_channels to resolve the named destination, then call post_message once with that exact channel_id. Do not substitute your dedicated or source channel for the requested destination. Return a short confirmation for the watcher to deliver in this source thread.' : 'Do NOT call post_message; it is intentionally unavailable. Return only the final reply as your final answer.'} The watcher will re-check the live thread and render it at most once.${who ? ` To mention the requester, use their exact full name "@${who}".` : ''} Use additional discovery, resources, and history when they help answer this request.`
|
|
2108
2141
|
: `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.'}`
|
|
2109
2142
|
: undefined
|
|
2110
2143
|
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.'
|
|
@@ -2114,7 +2147,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2114
2147
|
// pickup message; the first channel message is the verified result or a
|
|
2115
2148
|
// concrete blocker from the work lane.
|
|
2116
2149
|
void drain('full', ctx, cid == null ? [] : [cid], null, replyDelivery)
|
|
2117
|
-
} else void drain(creatingTicket ? 'coord' : 'fast', ctx, cid == null ? [] : [cid], null, replyDelivery)
|
|
2150
|
+
} else void drain(creatingTicket || channelMessaging ? 'coord' : 'fast', ctx, cid == null ? [] : [cid], null, replyDelivery)
|
|
2118
2151
|
} else if (k === 'error') {
|
|
2119
2152
|
const detail = raw && (raw.message || raw.error || raw.reason || raw.code || raw.d?.message || raw.d?.error)
|
|
2120
2153
|
log('error event: ' + (detail ? String(detail) : JSON.stringify(raw)).slice(0, 220))
|
|
@@ -2139,6 +2172,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2139
2172
|
activity.pulse()
|
|
2140
2173
|
emit('connection.connected', { status: 'connected' })
|
|
2141
2174
|
for (const wake of [...recoveryWaiters]) wake()
|
|
2175
|
+
queueMicrotask(() => { if (!stopping) void ensureSelfAgent() })
|
|
2142
2176
|
if (autoStart) setTimeout(() => { if (!stopping) void reconcileBacklog() }, 250)
|
|
2143
2177
|
}, log })
|
|
2144
2178
|
|
|
@@ -2189,6 +2223,10 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2189
2223
|
}
|
|
2190
2224
|
const deliveryTimer = autoStart ? setInterval(() => void flushPendingReplies(), 30_000) : null
|
|
2191
2225
|
deliveryTimer?.unref?.()
|
|
2226
|
+
const activityRecoveryTimer = autoStart ? setInterval(() => {
|
|
2227
|
+
if (!stopping) void trackOperation(reconcileActivity())
|
|
2228
|
+
}, 30_000) : null
|
|
2229
|
+
activityRecoveryTimer?.unref?.()
|
|
2192
2230
|
const authorizationTimer = autoStart ? setInterval(() => void resumeReadyAuthorizations(), 2_000) : null
|
|
2193
2231
|
authorizationTimer?.unref?.()
|
|
2194
2232
|
let modelSettingsWatcher = null
|
|
@@ -2228,6 +2266,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2228
2266
|
closePromise = (async () => {
|
|
2229
2267
|
clearInterval(authorizationTimer)
|
|
2230
2268
|
clearInterval(deliveryTimer)
|
|
2269
|
+
clearInterval(activityRecoveryTimer)
|
|
2231
2270
|
modelSettingsWatcher?.close()
|
|
2232
2271
|
if (introTimer) clearTimeout(introTimer)
|
|
2233
2272
|
try { handle?.close() } catch { /* already disconnected */ }
|
|
@@ -2252,6 +2291,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2252
2291
|
}
|
|
2253
2292
|
return {
|
|
2254
2293
|
done, close, receive: onEvent, reconcile: () => stopping ? Promise.resolve() : trackOperation(reconcileBacklog()),
|
|
2294
|
+
recoverActivity: () => stopping ? Promise.resolve() : trackOperation(reconcileActivity()),
|
|
2255
2295
|
resumeAuthorizations: resumeReadyAuthorizations, retryDeliveries: flushPendingReplies,
|
|
2256
2296
|
get pending() { return queues.work.size + queues.reply.size + mentionIntake.size },
|
|
2257
2297
|
}
|