openvisio-agent 0.23.0 → 0.24.0

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,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.24.0] — 2026-09-10
4
+
5
+ - Acknowledge accepted coding tasks with the agent's short native plan, then deliver its final response separately. Deduplicate plans through reconnects and use guarded source-thread delivery for every BYO runtime.
6
+ - Enable native coding delegation through Codex multi-agent, OpenCode task, and Claude Agent/Task, with scoped child objectives and parent review.
7
+ - Restore working, thinking, and typing presence through the documented REST endpoint, with per-cycle ownership, throttled refresh, and failure backoff.
8
+ - Remove Studio input outlines and retain a subtle keyboard-focus underline.
9
+ - Keep accepted coding work alive across event WebSocket disconnects. Resume confirmed runtime transport interruptions in the same queue with the original workspace, thread, and partial context; verify ownership again before restarting the model.
10
+ - Wait through known event-channel outages without exhausting retries. Use bounded backoff for provider-only transport failures; preserve agent final responses, cancellation, policy blocks, and guarded delivery.
11
+ - Refresh active cycle state in the Studio journal so long tasks remain visible after their start event leaves the bounded activity window.
12
+
3
13
  ## [0.23.0] — 2026-09-10
4
14
 
5
15
  - Add local semantic and keyword search over recorded conversations with source references, bounded retention, edit replacement, and backend/agent isolation.
package/README.md CHANGED
@@ -85,7 +85,7 @@ The backend MCP may be stateful or stateless. A successful initialize response w
85
85
 
86
86
  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.
87
87
 
88
- 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 its own authenticated agent profile and restores that subscription after reconnect. 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 does not broadcast working, thinking, or typing presence updates. Claude and OpenCode may add one concrete progress update after work begins, but must continue and post a distinct verified result or blocker afterward; Codex keeps cancellation-safe delivery watcher-owned and renders the verified final answer once.
88
+ 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 its own authenticated agent profile and restores that subscription after reconnect. 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 in the source thread or dedicated channel. Every runtime returns a distinct final answer for guarded watcher delivery. Plans and reconnects cannot duplicate or suppress that final answer.
89
89
 
90
90
  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.
91
91
 
@@ -98,7 +98,7 @@ openvisio-agent watch --name ada --workdir ~/repo # allow REAL work on a git br
98
98
  openvisio-agent stop --name ada # stop service + every ada watcher
99
99
  ```
100
100
 
101
- Agents choose their tools, investigation steps, context management, and when to end a turn. Each turn receives the agent’s configured identity, role, and voice when supplied by its profile; that context is cached across restarts. Reply sessions can request their configured coding workspace through `openvisio_request_work_session`, carrying their findings, source thread, and permissions into the continuation. They do not ask you to reassign work because of an internal scheduling choice. A successful native final turn ends the cycle; it does not mark a ticket done. The agent decides when to update the board. Research, audits, and already-satisfied requests can finish without code changes, ticket mutations, or a PR. Tool failures stay visible as diagnostics without forcing another model turn or replacing the agent's explanation.
101
+ Agents choose their tools, investigation steps, context management, and when to end a turn. Coding agents can delegate independent subtasks through the runtime’s native sub-agent tools: Codex multi-agent is enabled, OpenCode permits task delegation, and Claude permits Agent/Task. The parent supplies scoped context and file ownership, works alongside children, reviews their results, and reports once. Delegation depends on the tools advertised by the installed runtime; it does not grant additional task or publishing authority. Each turn receives the agent’s configured identity, role, and voice when supplied by its profile; that context is cached across restarts. Reply sessions can request their configured coding workspace through `openvisio_request_work_session`, carrying their findings, source thread, and permissions into the continuation. They do not ask you to reassign work because of an internal scheduling choice. A successful native final turn ends the cycle; it does not mark a ticket done. The agent decides when to update the board. Research, audits, and already-satisfied requests can finish without code changes, ticket mutations, or a PR. Tool failures stay visible as diagnostics without forcing another model turn or replacing the agent's explanation.
102
102
 
103
103
  The context graph records source-to-continuation relationships and recalls direct neighbors without walking into unrelated conversations. The watcher saves the agent's final response for delivery retry and avoids rerunning an unchanged assignment after restart. Claude, Codex, and OpenCode keep context within a ticket or conversation; native runtimes handle compaction. A stalled-session watchdog resets when activity arrives, so ongoing work is not stopped by a fixed task duration. Ownership, cancellation, delivery deduplication, credential injection, and repository permissions remain enforced.
104
104
 
@@ -116,6 +116,8 @@ Codex and OpenCode use Mastra ACP directly. Work sessions are retained per ticke
116
116
 
117
117
  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.
118
118
 
119
+ An event WebSocket disconnect does not cancel accepted coding work. If the native coding runtime also reports a transport interruption, its queue entry retains the source request, workspace, and partial output. It waits for a known event-channel outage to reconnect, then verifies the ticket again and asks the agent to inspect existing work and uncertain remote writes before continuing. Provider-only interruptions use up to four retries (2, 8, 30, and 60 seconds); repeated failure then reports a blocker. Agent final responses, optional tool errors, explicit cancellation, inactivity timeouts, quota errors, and policy denials do not trigger this recovery. Coordination requests are not automatically replayed. Keep the watcher running during an outage; this recovery retains in-flight context in that watcher, not across process termination.
120
+
119
121
  See `docs/BYO_PERFORMANCE_AUDIT_2026-09-08.md` in the repository for the audit results and remaining live-validation limits. Local certification does not certify a deployed agent's behavior.
120
122
 
121
123
  `--install` sets up a background service (launchd on macOS, systemd `--user` on Linux) that runs `watch` and restarts on login. Logs go to `~/.openvisio/<agent>.log` (macOS) or `journalctl --user -u openvisio-<agent>` (Linux).
package/USER_GUIDE.md CHANGED
@@ -76,6 +76,8 @@ Stopping a watcher interrupts its active work. When you are ready to resume, run
76
76
 
77
77
  Replies stay in their source thread. New completion messages use the agent’s dedicated channel. 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
+ 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
+
79
81
  ## Open Agent Studio
80
82
 
81
83
  In the app, choose **Agents → Open Agent Studio**. Updated backend watchers start Studio automatically. Agent chat replies can also show this button and open the relevant agent’s settings directly.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.23.0",
4
- "description": "Connect Claude Code, Codex, or OpenCode to an OpenVisio team MCP tools + optional autonomy in one command.",
3
+ "version": "0.24.0",
4
+ "description": "Connect Claude Code, Codex, or OpenCode to an OpenVisio team \u2014 MCP tools + optional autonomy \u2014 in one command.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "openvisio-agent": "bin/cli.mjs"
@@ -321,7 +321,7 @@ for (const agent of ['codex', 'opencode']) for (const mode of modes) for (const
321
321
  }
322
322
  if (agent === 'opencode') {
323
323
  const value = JSON.parse(config.env.OPENCODE_CONFIG_CONTENT)
324
- if (mode.canCode) assert.equal(value.permission, undefined)
324
+ if (mode.canCode) assert.deepEqual(value.permission, { task: 'allow' })
325
325
  else {
326
326
  assert.equal(value.permission.bash, 'deny')
327
327
  assert.equal(value.permission['openvisio-team-watcher_*'], 'allow')
@@ -16,11 +16,11 @@ async function eventually(predicate, description) {
16
16
  assert.fail(`Watcher did not reach: ${description}`)
17
17
  }
18
18
 
19
- export function fixture({ agent = 'codex', task = true, run, threadError = false, commentTools = [], onComment, selfProfile = self, listAgents, getTicket, listChannels } = {}) {
19
+ export function fixture({ agent = 'codex', task = true, run, threadError = false, commentTools = [], onComment, selfProfile = self, listAgents, getTicket, listChannels, 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
- let watcher, messageId = 100, closed = 0
23
+ let watcher, connection, messageId = 100, closed = 0
24
24
  const memory = createByoMemoryGraph({ path: join(dir, 'ledger.json') })
25
25
  const mcp = {
26
26
  listTools: async () => ['list_agents', 'list_projects', 'list_tasks', 'get_ticket', 'update_ticket', 'post_message', 'list_message_thread', 'list_channels', 'list_task_types', 'list_activity', ...commentTools].map((name) => ({ name })),
@@ -71,7 +71,7 @@ export function fixture({ agent = 'codex', task = true, run, threadError = false
71
71
  }
72
72
  const start = () => {
73
73
  watcher = createBackendWatcher({ wsUrl: 'wss://unused.example.test', mcpUrl: 'https://unused.example.test/mcp', apiKey: 'test-key', identifier: 'alex', slug: 'test-alex', agent, workdir: join(dir, 'workspace'), model: 'test-code', chatModel: 'test-chat' }, {
74
- stateDir: dir, memory, mcpClient: mcp, createRunner, connect: () => ({ close() {}, subscribeOrganization(id) { subscriptions.push(id) } }), autoStart: false, log: (value) => logs.push(value),
74
+ stateDir: dir, memory, mcpClient: mcp, createRunner, transportRecoveryDelays, activityReporter, connect: (callbacks) => { connection = callbacks; return { close() {}, subscribeOrganization(id) { subscriptions.push(id) } } }, autoStart: false, log: (value) => logs.push(value),
75
75
  })
76
76
  return watcher
77
77
  }
@@ -79,6 +79,7 @@ export function fixture({ agent = 'codex', task = true, run, threadError = false
79
79
  return {
80
80
  dir, ticket, posts, runs, calls, logs, messages, memory, subscriptions,
81
81
  get watcher() { return watcher },
82
+ get connection() { return connection },
82
83
  get closed() { return closed },
83
84
  set threadError(value) { threadError = value },
84
85
  start,
@@ -91,7 +91,7 @@ const assertions = [
91
91
  ['workers serialize shared worktrees while independent worktrees run concurrently', watcher.includes('groupKey: (item) => item.workdir || workdir') && cycleQueue.includes('active.size < concurrency') && cycleQueue.includes('activeGroups.has(candidate[1].group)')],
92
92
  ['work and reply cancellation targets are isolated', watcher.includes('item.control.cancelled = true') && watcher.includes('item.control.runner?.cancelCurrent')],
93
93
  ['fresh threads resolve the dedicated agent channel', watcher.includes('dedicatedChannel(Array.isArray(channels)') && watcher.includes('No dedicated agent channel available for a new thread')],
94
- ['presence notification requests are disabled', watcher.includes('const sendStatus = () => {}') && !watcher.includes('agentStateRequest(') && !watcher.includes("setInterval(() => emitStatusTargets")],
94
+ ['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)')],
95
95
  ['websocket client cannot emit legacy agent_status', !websocket.includes('agent_status')],
96
96
  ['frontend consumes thinking event', activityHook.includes("'channel:agent:thinking'")],
97
97
  ['frontend consumes working event', activityHook.includes("'channel:agent:working'")],
@@ -0,0 +1,47 @@
1
+ import { agentStateRequest } from './events.mjs'
2
+
3
+ // Presence is ephemeral, independent of task completion and message delivery.
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 }) {
6
+ const cycles = new Map(), channels = new Map(), pending = new Set()
7
+ let closed = false
8
+ const pulse = () => {
9
+ if (closed || !backend) return
10
+ const states = new Map()
11
+ const priority = { thinking: 0, working: 1, typing: 2 }
12
+ for (const cycle of cycles.values()) for (const channelId of cycle.channels) {
13
+ if (!states.has(channelId) || priority[cycle.state] > priority[states.get(channelId)]) states.set(channelId, cycle.state)
14
+ }
15
+ for (const [channelId, state] of states) {
16
+ const prior = channels.get(channelId)
17
+ if (prior?.busy || now() < (prior?.retryAt || 0) || (prior?.state === state && now() - prior.at < (state === 'working' ? 10_000 : 3_000))) continue
18
+ const controller = new AbortController()
19
+ const timer = setTimeout(() => controller.abort(), 5_000)
20
+ timer.unref?.()
21
+ const entry = { state, at: now(), busy: true, controller }
22
+ channels.set(channelId, entry)
23
+ const request = agentStateRequest(backend, channelId, state, apiKey, identifier)
24
+ const operation = Promise.resolve().then(() => fetchImpl(request.url, { ...request.init, signal: controller.signal })).then(async response => {
25
+ await response.body?.cancel?.()
26
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
27
+ }).catch(error => {
28
+ entry.retryAt = now() + 30_000
29
+ if (!closed) log('Activity update unavailable; backing off: ' + String(error?.message || 'request failed').slice(0, 100))
30
+ }).finally(() => { clearTimeout(timer); entry.busy = false; pending.delete(operation) })
31
+ pending.add(operation)
32
+ }
33
+ }
34
+ const timer = intervalMs > 0 ? setInterval(pulse, intervalMs) : null
35
+ timer?.unref?.()
36
+ return {
37
+ set(cycleId, channelIds, state) {
38
+ if (closed || !['thinking', 'working', 'typing'].includes(state)) return
39
+ const valid = [...new Set([...channelIds].map(Number).filter(id => Number.isSafeInteger(id) && id > 0))]
40
+ cycles.set(cycleId, { channels: valid, state }); pulse()
41
+ },
42
+ clear(cycleId) { cycles.delete(cycleId); pulse() },
43
+ pulse,
44
+ 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]) },
46
+ }
47
+ }
@@ -8,6 +8,7 @@ import { codexPolicyBlock } from './events.mjs'
8
8
  import { WORK_SESSION_TOOL, workSessionRequest } from './runtime-control.mjs'
9
9
  import { contextToolNames } from './context-tools.mjs'
10
10
  import { buildOpencodeConfig } from './opencode-config.mjs'
11
+ import { isTransportInterruption } from './transport-recovery.mjs'
11
12
 
12
13
  const MCP_TOOL_NAMES = [
13
14
  WORK_SESSION_TOOL, ...contextToolNames, 'list_agents', 'list_projects', 'list_tasks', 'list_task_types', 'get_ticket',
@@ -196,7 +197,7 @@ export function createMastraAcpRunner({
196
197
  // codex-acp otherwise keeps a pre-existing openvisio-team server and
197
198
  // silently discards this session's authenticated stdio bridge.
198
199
  DISABLE_MCP_CONFIG_FILTERING: 'true',
199
- CODEX_CONFIG: JSON.stringify({ 'mcp_servers.openvisio-team.enabled': false }),
200
+ CODEX_CONFIG: JSON.stringify({ 'mcp_servers.openvisio-team.enabled': false, ...(canCode ? { 'features.multi_agent': true } : {}) }),
200
201
  } : agent === 'opencode' ? {
201
202
  // Reply sessions can explore and manage context without a writable workspace.
202
203
  OPENCODE_CONFIG_CONTENT: JSON.stringify({
@@ -398,7 +399,7 @@ export function createMastraAcpRunner({
398
399
  log(`${agent} cycle done via Mastra ACP (${subtype}${!canceled && safeError ? ': ' + clean(safeError, 1200) : ''})`)
399
400
  invalidate()
400
401
  return {
401
- type: 'result', subtype, userMessage: providerFailure, runtime: 'mastra-acp', model: selectedModel || null, requestedModel: requestedModel || null, outputText: outputText.trim(),
402
+ type: 'result', subtype, transportInterrupted: subtype === 'error' && isTransportInterruption(error), userMessage: providerFailure, runtime: 'mastra-acp', model: selectedModel || null, requestedModel: requestedModel || null, outputText: outputText.trim(),
402
403
  mcpCalls: [...calls], mcpErrors: [...errors.keys()], mcpErrorDetails: Object.fromEntries(errors),
403
404
  didCode, didRepoMutation, didMessage, didChannelMessage, didResultMessage,
404
405
  didMcpTaskRead, didMcpTaskUpdate,
@@ -12,7 +12,6 @@ export function opencodeRuntimeLayout({ cfgKey, workdir, baseDir = OV_DIR }) {
12
12
  }
13
13
 
14
14
  export function buildOpencodeConfig({ mcpUrl, mcpHeaders, canCode = true }) {
15
- if (!mcpUrl && canCode) return null
16
15
  // Keep local writes scoped to coding connections while allowing the agent
17
16
  // to explore tools, retrieve context, plan, and use native compaction.
18
17
  const replyPermissions = {
@@ -36,6 +35,7 @@ export function buildOpencodeConfig({ mcpUrl, mcpHeaders, canCode = true }) {
36
35
  }
37
36
  return {
38
37
  $schema: 'https://opencode.ai/config.json',
38
+ ...(canCode ? { permission: { task: 'allow' } } : {}),
39
39
  ...(!canCode ? {
40
40
  permission: replyPermissions,
41
41
  // Agent-level rules take precedence over global permissions. Select this
@@ -0,0 +1,25 @@
1
+ // Only runtime/transport diagnostics belong here. Agent prose and failed
2
+ // optional tool calls must never turn a successful final response into a retry.
3
+ const transportCodes = new Set(['ECONNRESET', 'ECONNREFUSED', 'ECONNABORTED', 'EPIPE', 'ETIMEDOUT', 'ENETDOWN', 'ENETUNREACH', 'EHOSTUNREACH', 'EAI_AGAIN', 'UND_ERR_SOCKET', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT'])
4
+
5
+ export function isTransportInterruption(error) {
6
+ const visited = new Set()
7
+ const inspect = (value) => {
8
+ if (!value || visited.has(value)) return false
9
+ visited.add(value)
10
+ if (typeof value === 'object') {
11
+ if (transportCodes.has(String(value.code || '').toUpperCase())) return true
12
+ return inspect(value.message) || inspect(value.cause) || inspect(value.data) || (Array.isArray(value.errors) && value.errors.some(inspect))
13
+ }
14
+ if (typeof value !== 'string') return false
15
+ return /\b(?:ECONNRESET|ECONNREFUSED|EPIPE|ENETUNREACH|EAI_AGAIN|UND_ERR_SOCKET)\b|socket hang up|stream disconnected before completion|(?:connection|websocket|transport) (?:was |is |unexpectedly )?(?:closed|lost|terminated)|network connection (?:lost|failed)/i.test(value)
16
+ }
17
+ return inspect(error)
18
+ }
19
+
20
+ export function interruptedRuntimeResult(result) {
21
+ if (!result || ['ok', 'success', 'canceled', 'cancelled', 'blocked', 'rate_limited', 'timeout'].includes(result.subtype) || result.policyBlock) return false
22
+ return result.transportInterrupted === true || isTransportInterruption(result.error) || isTransportInterruption({ errors: result.errors })
23
+ }
24
+
25
+ export const transportRecoveryDelays = [2_000, 8_000, 30_000, 60_000]
package/src/watch.mjs CHANGED
@@ -22,6 +22,8 @@ import { repositoryHasPrPushAuthorization, configurePrPublishing } from './pr-pu
22
22
  import { createMcpHttpClient } from './mcp-http.mjs'
23
23
  import { createCycleQueue } from './cycle-queue.mjs'
24
24
  import { createRunnerPool } from './runner-pool.mjs'
25
+ import { createActivityReporter } from './activity-reporter.mjs'
26
+ import { interruptedRuntimeResult, isTransportInterruption, transportRecoveryDelays } from './transport-recovery.mjs'
25
27
  import { mapConcurrent } from './concurrency.mjs'
26
28
  import { releaseAuthorizedPause } from './authorization-resume.mjs'
27
29
  import { dedicatedChannel, repliesAfterSource } from './channel-routing.mjs'
@@ -79,7 +81,7 @@ const REPLY_DISCIPLINE = [
79
81
  ' • FIRST-PERSON VOICE. Speak as yourself: use “I”, “I\'m”, and “my”. Never refer to yourself by your agent name or in the third person, and never restate your own name in introductions, acknowledgements, progress, blockers, or results. The app already shows who sent the message. Sound like a warm, accountable teammate, not a status bot.',
80
82
  ' • IS IT FOR YOU? Act ONLY on messages addressed to YOU — an @mention of your exact name, a direct question to you, or a reply to something YOU said or did. If a DIFFERENT agent or person was @mentioned or asked to do something, STAY OUT: do not answer for them and do not pick up their task. When it is not yours, posting nothing is the correct move.',
81
83
  ' • EVENT NAMES ARE NOT OWNERSHIP. A transport may wake you for activity in a thread you once joined. Trust only the watcher\'s verified recipient decision for the current source message; never infer that every thread update is yours.',
82
- ' • NO DUPLICATES OR PICKUP NOISE. Before you post, scan the recent thread/channel for what YOU already said. If you already replied to this exact request, do NOT repeat the same message. Do not send a generic pickup acknowledgement; activity shows that work is underway. For longer code work, you may send at most one concrete progress update after work has actually begun, but that update NEVER completes the cycle: keep using tools, then send one distinct verified result or real blocker. One final answer per question.',
84
+ ' • ACKNOWLEDGE TASKS, THEN DO THE WORK. Before starting an accepted task, publish a short, concrete plan using the native planning tool. The watcher sends one acknowledgement with your first 2-3 steps to the source thread. Do not post the same acknowledgement yourself. An acknowledgement or plan never completes the task: continue working, then give one distinct verified result or real blocker. Ordinary questions need only their answer; reconnects must not repeat the task acknowledgement.',
83
85
  ' • 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.',
84
86
  ' • 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.',
85
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.',
@@ -140,6 +142,8 @@ const CODE_CHARTER = [
140
142
 
141
143
  const CODE_FULL = [
142
144
  'Handle the supplied verified ticket or source request. You decide how to investigate, which tools to use, what context to retain or compact, and when the work is ready for a final response.',
145
+ 'Before starting the task, use your native planning tool (update_plan, TodoWrite, or todowrite when advertised) to publish 2-3 short, concrete steps based on the supplied request. The watcher delivers this as your acknowledgement and brief plan. Keep working after the plan; only your final response ends the turn. Do not repeat the acknowledgement after a transport recovery.',
146
+ 'DELEGATION: Use native sub-agents for independent subtasks when they can shorten the work. Discover the runtime tools actually available: Codex spawn_agent/wait/send_input/close_agent, Claude Agent/Task, or OpenCode task. Give each child a bounded objective, relevant context, and explicit file ownership or a separate worktree. Work on another independent part while they run. Children inherit the current task authority; they cannot bypass a policy block, publish unrelated work, or answer the teammate separately. Review and integrate their results, close finished children, and deliver one parent response. Keep delegation bounded (normally at most three children); do not delegate trivial or dependent steps. If the runtime lacks delegation, continue locally and be clear about that limit.',
143
147
  'There is no required sequence of tools, code edits, ticket updates, or PR creation. Research, audits, explanations, coordination, and already-satisfied requests may finish without repository changes. Choose validation and delivery appropriate to the actual request.',
144
148
  'Use available tool discovery, resources, history, search, skills, and planning tools when useful. Prefer the repository knowledge graph when available. Recover from optional tool failures using alternatives; report only a limitation that actually prevents the requested outcome.',
145
149
  'A usable local clone is your primary code surface. Preserve unrelated work and use an isolated worktree when needed. Remote codebase tools are a fallback only when the repository cannot be obtained locally. For code changes, verify the result and use the authorized branch/PR flow when the request calls for it.',
@@ -152,7 +156,7 @@ const CODE_FAST = [
152
156
  ' • IF a specific mention/message FOR YOU is given above: reply to THAT ONE message exactly once with post_message, then STOP. Do NOT call poll_inbox and do NOT answer anything else this cycle — polling would re-surface the same message and make you double-post.',
153
157
  ' • IF NO specific mention is given above: call poll_inbox and reply only to items directed at YOU (asks you something, or responds to your own message) — SKIP chatter aimed at someone else / another agent; at most one reply per channel.',
154
158
  'Explore the request and its context. If local execution or edits are needed, use openvisio_request_work_session when advertised to continue in your own coding workspace. Do not turn an internal scheduling choice into a reassignment request.',
155
- 'For a non-code question, post ONE answer and stop. For code work, do not post a generic pickup message. You may post one concrete progress update after work starts, but keep working after it; then post one distinct final result with the PR/test evidence or a real blocker. Never repeat the same message. Let the request determine how much detail the final response needs.',
159
+ 'For a non-code question, post ONE answer and stop. For code work, continue into your coding workspace, publish the short task plan for watcher acknowledgement, and keep working; then give one distinct final result with the evidence or a real blocker. Never repeat the acknowledgement or result.',
156
160
  ].join('\n')
157
161
 
158
162
  // ── Workspace-ethics cycles (both chat-only + code agents) ───────────────────
@@ -167,7 +171,7 @@ const INTRO = [
167
171
  const SWEEP = [
168
172
  'DAILY CATCH-UP — you may have missed items while offline. Prioritize TASKS.',
169
173
  'Use the available task/inbox tools. If get_marching_orders/poll_inbox are absent, use list_agents + list_projects + list_tasks to find tasks assigned to your agent identity, then:',
170
- ' 1. For every task assigned to YOU that you have NOT started: begin the work without a pickup acknowledgement. Use update_ticket while working and report only a verified result or concrete blocker through an actual source channel when one is supplied. Then do the work end-to-end. Skip tasks assigned to other agents.',
174
+ ' 1. For every task assigned to YOU that you have NOT started: publish a short task plan for watcher acknowledgement, then do the work end-to-end. Use update_ticket when appropriate and report a verified result or concrete blocker. Skip tasks assigned to other agents.',
171
175
  ' 2. Answer only the @mentions / follow-ups that were directed at YOU and that you have not already answered — at most one reply per channel. Do not reply to threads aimed at someone else.',
172
176
  'If there is genuinely nothing outstanding, STOP silently — do NOT post a "nothing to do" message.',
173
177
  ].join('\n')
@@ -344,6 +348,7 @@ export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, m
344
348
  let child = null
345
349
  let privateMcpDir = ''
346
350
  let contextEnvironment = {}
351
+ let disabledMcpTools = []
347
352
  // The model the CURRENT session was spawned with. runCycle can pass a different
348
353
  // model per cycle (cheap for chat, stronger for code) — a change recycles the
349
354
  // session so the new model takes effect.
@@ -428,6 +433,7 @@ export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, m
428
433
  OPENVISIO_CODEX_IDENTIFIER: mcpHeaders['x-agent-identifier'],
429
434
  OPENVISIO_CAN_CODE: String(canCode),
430
435
  OPENVISIO_WORKSPACE_AVAILABLE: String(workspaceAvailable),
436
+ OPENVISIO_CODEX_DISABLED_TOOLS: JSON.stringify(disabledMcpTools),
431
437
  ...contextEnvironment,
432
438
  },
433
439
  } } }, true)
@@ -510,6 +516,12 @@ export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, m
510
516
 
511
517
  async function runCycle(prompt, cycleModel, cycleOptions = {}) {
512
518
  contextEnvironment = await getContextEnvironment?.() || {}
519
+ const nextDisabled = [...(cycleOptions.disabledMcpTools || [])].sort()
520
+ if (JSON.stringify(nextDisabled) !== JSON.stringify(disabledMcpTools) && child) {
521
+ const previous = child; child = null
522
+ await stopModelProcess(previous)
523
+ }
524
+ disabledMcpTools = nextDisabled
513
525
  return new Promise((resolve) => {
514
526
  turnCycleId = cycleOptions.cycleId ?? null
515
527
  // A per-cycle model override (e.g. chat on a cheaper model than code) — a
@@ -597,6 +609,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
597
609
  const journal = runtimeJournal({ stateDir, agent: { identifier, slug, provider: agent }, secrets: [apiKey] }, dependencies.journal)
598
610
  const emit = (type, data = {}) => journal.emit(type, { watcherPid: process.pid, ...data })
599
611
  let cycleSequence = 0
612
+ const liveCycles = new Map()
600
613
  let mcpSequence = 0
601
614
  const cycleContext = new AsyncLocalStorage()
602
615
  emit('watcher.started', { status: 'running', model, chatModel, workdir: workdir || '' })
@@ -604,10 +617,23 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
604
617
  // Activity belongs to an individual cycle. Independent ticket workers must
605
618
  // not clear or overwrite each other's status targets.
606
619
  const replyStatusTargets = new Set()
607
- // Working/thinking/typing presence updates are intentionally silent. They
608
- // generated notification noise without adding durable progress evidence.
609
- const sendStatus = () => {}
620
+ const activity = dependencies.activityReporter || createActivityReporter({ backend, apiKey, identifier, log })
621
+ const sendStatus = (channelId, state) => {
622
+ const id = cycleContext.getStore()?.cycleId
623
+ const live = liveCycles.get(id)
624
+ if (live && !live.control.cancelled) activity.set(id, live.control.statusTargets.size ? live.control.statusTargets : [channelId], state)
625
+ }
610
626
  const emitStatusTargets = (targets, state) => { for (const c of targets) sendStatus(c, state) }
627
+ const runtimeEvent = (type, data = {}) => {
628
+ emit(type, data)
629
+ const live = liveCycles.get(data.cycleId)
630
+ if (!live || live.control.cancelled || stopping) return
631
+ if (type === 'tool.started' || type === 'tool.updated') activity.set(data.cycleId, live.control.statusTargets, 'working')
632
+ if (type === 'output.progress') activity.set(data.cycleId, live.control.statusTargets, 'typing')
633
+ if (type === 'plan.updated' && live.data.kind === 'full' && data.entries?.some(entry => typeof entry?.content === 'string' && entry.content.trim()) && !live.control.planDelivery) {
634
+ live.control.planDelivery = trackOperation(publishWorkPlan(live, data.entries)).catch(error => log('Task plan delivery unavailable: ' + (error?.message || error)))
635
+ }
636
+ }
611
637
  const canCode = !!workdir
612
638
  // The Mastra bridge authenticates per-CALL: every openvisio-team tool needs
613
639
  // agent_identifier + agent_api_key as arguments. Hand them over up front.
@@ -618,7 +644,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
618
644
  const systemPrompt = (canCode ? CODE_CHARTER : CHAT_CHARTER) + '\n\n' + credNote
619
645
  const runnerOptions = {
620
646
  claude, agent, mcpUrl, mcpHeaders: { 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier },
621
- cfgKey: identifier, mcpConfig, workdir, workspaceAvailable: canCode, log, debug, model, systemPrompt, onEvent: emit,
647
+ cfgKey: identifier, mcpConfig, workdir, workspaceAvailable: canCode, log, debug, model, systemPrompt, onEvent: runtimeEvent,
622
648
  getContextEnvironment: () => contextService.environment(),
623
649
  }
624
650
  // One watcher and one WS subscription, with a bounded pool of isolated coding
@@ -626,6 +652,9 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
626
652
  // concurrently behind the same delivery guards.
627
653
  const MAX_CONCURRENT_WORKERS = 3
628
654
  const activeWorkRunners = new Set()
655
+ const recoveryWaiters = new Set()
656
+ const recoveryDelays = dependencies.transportRecoveryDelays || transportRecoveryDelays
657
+ let eventConnection = 'unknown'
629
658
  const createWorkRunner = (control, workerWorkdir = '') => makeRunner({
630
659
  ...runnerOptions,
631
660
  ...(workerWorkdir ? { workdir: workerWorkdir } : {}),
@@ -652,7 +681,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
652
681
  const fullPrompt = (canCode ? CODE_FULL + codexPushGuide : 'Handle the supplied verified backend ticket with the available OpenVisio tools. Update or comment on the ticket as requested, do not claim repository work in chat-only mode, and stop after the verified action.') + '\n\n' + backendToolRule + studioGuide
653
682
  const fastPrompt = (canCode ? CODE_FAST : CYCLE_FAST) + '\n\n' + backendToolRule + studioGuide
654
683
  const coordinatePrompt = COORDINATE + '\n\n' + backendToolRule + studioGuide
655
- const guardedReplyPrompt = `WATCHER-DELIVERED REPLY: the watcher has already verified that the current source message is addressed to you. Your current OpenVisio agent identifier is "${identifier}"; do not call list_agents merely to rediscover yourself. Return the final reply for watcher delivery instead of calling post_message for the same result. Discover tools and retrieve additional context as needed. OpenVisio team-state tools are allowed: when the answer depends on live projects, assignments, tickets, or status, use list_projects/list_tasks/get_ticket as needed and never ask the teammate for a slug that those tools can resolve. A failed read-only discovery call is not a completed action and must not be described as an intervention-level blocker; continue with available live data or state the narrow fact you could not verify. Return one natural, context-specific final response with enough detail to address the request. Do not echo the request, announce a plan, or add a generic acknowledgement. If the supplied context says the work was completed, lead with the verified result; if it is blocked, name only the real blocker and next action.` + '\n\n' + backendToolRule + studioGuide
684
+ const guardedReplyPrompt = `WATCHER-DELIVERED REPLY: the watcher has already verified that the current source message is addressed to you. Your current OpenVisio agent identifier is "${identifier}"; do not call list_agents merely to rediscover yourself. Return the final reply for watcher delivery instead of calling post_message for the same result. Discover tools and retrieve additional context as needed. OpenVisio team-state tools are allowed: when the answer depends on live projects, assignments, tickets, or status, use list_projects/list_tasks/get_ticket as needed and never ask the teammate for a slug that those tools can resolve. A failed read-only discovery call is not a completed action and must not be described as an intervention-level blocker; continue with available live data or state the narrow fact you could not verify. Return one natural, context-specific final response with enough detail to address the request. Do not echo the request or replace the final answer with a plan. Task acknowledgements are delivered separately from your native plan; ordinary questions need only their answer. If the supplied context says the work was completed, lead with the verified result; if it is blocked, name only the real blocker and next action.` + '\n\n' + backendToolRule + studioGuide
656
685
  // Live model state — changeable at runtime by the in-chat `/model` command.
657
686
  // codeModel drives full/sweep cycles; chatModel (if set) the lighter fast/intro
658
687
  // ones, so routine chatter can run cheaper than real code work.
@@ -681,7 +710,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
681
710
  const runner = workRunners.acquire(sessionKey, { control: item.control, workdir: item.workdir })
682
711
  item.control.runner = runner
683
712
  activeWorkRunners.add(runner)
684
- try { return await cycleContext.run({ cycleId: item.control.cycleId }, () => executeCycle(item.kind, item.context, item.targetChannels, item.taskRef, item.delivery, runner, item.control, item.workdir)) }
713
+ try { return await cycleContext.run({ cycleId: item.control.cycleId }, () => executeRecoverableCycle(item, runner)) }
685
714
  finally { workRunners.release(sessionKey); activeWorkRunners.delete(runner); item.control.runner = null }
686
715
  },
687
716
  onError: (error, item) => {
@@ -743,7 +772,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
743
772
  const finishedTaskVersions = new Map(Array.isArray(replayState.finishedTaskVersions) ? replayState.finishedTaskVersions : [])
744
773
  const reportedCompletions = new Set(Array.isArray(replayState.reportedCompletions) ? replayState.reportedCompletions : [])
745
774
  const reportedTaskComments = new Set(Array.isArray(replayState.reportedTaskComments) ? replayState.reportedTaskComments : [])
746
- // Transport/runtime failures are held at the ticket revision that produced them.
775
+ // Unrecoverable runtime failures are held at the ticket revision that produced them.
747
776
  // This is distinct from a policy block: any later human ticket change resumes
748
777
  // the work, but reconnects and the watcher's own blocker update do not.
749
778
  const failedTaskVersions = new Map(replayState.completionPolicy === 'agent' && Array.isArray(replayState.failedTaskVersions) ? replayState.failedTaskVersions : [])
@@ -1030,6 +1059,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1030
1059
  for (const [laneName, queue] of Object.entries(queues)) {
1031
1060
  queue.cancel((item) => sameDeliveryThread(item.delivery, channelId, parentId), (item) => {
1032
1061
  item.control.cancelled = true
1062
+ item.control.wakeRecovery?.()
1033
1063
  log(`${laneName} lane cancelled by a newer redirect/stand-down in thread ${parentId}`)
1034
1064
  void item.control.runner?.cancelCurrent?.('thread-cancelled')
1035
1065
  })
@@ -1058,7 +1088,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1058
1088
  identity: () => ({ id: selfAgentId, identifier, slug, name: slug }),
1059
1089
  load: async (cid, root) => toolData(await callMcpTool('list_message_thread', { channel_id: Number(cid), message_id: Number(root) })),
1060
1090
  })
1061
- const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '', sourceMessageId, allowCancelled = false }) => {
1091
+ const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '', sourceMessageId, allowCancelled = false, isCancelled = () => false }) => {
1062
1092
  if (stopping) throw new Error('Watcher is stopping')
1063
1093
  const deliveryKey = String(key || '')
1064
1094
  if (parentId == null) {
@@ -1075,6 +1105,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1075
1105
  if (messageDeliveries.has(deliveryKey)) return messageDeliveries.get(deliveryKey)
1076
1106
  const controlKey = threadControlKey(channelId, parentId)
1077
1107
  const suppressCancelled = () => {
1108
+ if (isCancelled()) return { posted: false, reason: 'cycle-cancelled' }
1078
1109
  if (memory.has(deliveryKey, 'suppressed')) return { posted: false, reason: 'source-cancelled' }
1079
1110
  if (allowCancelled || !controlKey || !memory.has(controlKey, 'cancelled')) return null
1080
1111
  pendingReplyDeliveries.delete(deliveryKey)
@@ -1094,7 +1125,8 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1094
1125
  const run = (async () => {
1095
1126
  if (parentId != null) {
1096
1127
  const live = toolData(await callMcpTool('list_message_thread', { channel_id: Number(channelId), message_id: Number(parentId) }))
1097
- const rendered = repliesAfterSource(renderedAgentMessages(live, { id: selfAgentId, identifier, slug, name: slug }), sourceMessageId)
1128
+ const plans = skipIfAnyAgentReply ? memory.recall({ channelId: Number(channelId), threadId: Number(parentId) }, 1000).filter(node => node.kind === 'work-plan' && node.meta?.sourceKey === sourceKey) : []
1129
+ const rendered = repliesAfterSource(renderedAgentMessages(live, { id: selfAgentId, identifier, slug, name: slug }), sourceMessageId).filter(row => !plans.some(plan => normalizeRenderedMessageText(plan.summary) === normalizeRenderedMessageText(row.content)))
1098
1130
  const duplicate = rendered.some((row) => normalizeRenderedMessageText(row.content) === normalizeRenderedMessageText(message))
1099
1131
  if (duplicate || (skipIfAnyAgentReply && rendered.length)) {
1100
1132
  deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
@@ -1133,6 +1165,26 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1133
1165
  return run
1134
1166
  }
1135
1167
 
1168
+ async function publishWorkPlan(live, entries) {
1169
+ const { control, delivery, taskRef } = live
1170
+ 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))
1171
+ if (!steps.length || control.cancelled || !control.planKey || memory.has(control.planKey)) return
1172
+ const content = `${control.taskLabel ? `I'm on ${control.taskLabel}.` : "I'll take this on."}\n\n${steps.map((step, index) => `${index + 1}. ${step}`).join('\n')}`
1173
+ const channelId = delivery?.channelId ?? taskRef?.channelId ?? await projectStatusChannel(taskRef?.projectId)
1174
+ if (channelId == null || control.cancelled) return
1175
+ const refs = { channelId: Number(channelId), ...(delivery?.parentId != null ? { threadId: Number(delivery.parentId) } : {}) }
1176
+ // Remember intent before a potentially ambiguous write. A later final reply
1177
+ // must not be mistaken for a duplicate of this plan, even after reconnect.
1178
+ memory.remember({ key: control.planKey, kind: 'work-plan', state: 'attempted', summary: content, refs, meta: { sourceKey: delivery?.sourceKey || '' } })
1179
+ try {
1180
+ await postMessageOnce({ key: control.planKey + ':delivery', channelId, parentId: delivery?.parentId, projectId: taskRef?.projectId, sourceKey: delivery?.sourceKey, sourceMessageId: delivery?.sourceMessageId, content, isCancelled: () => control.cancelled })
1181
+ } finally {
1182
+ // An acknowledgement is only useful before work completes. Never leave a
1183
+ // delayed pickup message queued behind a delivered final response.
1184
+ pendingReplyDeliveries.delete(control.planKey + ':delivery'); persistReplay()
1185
+ }
1186
+ }
1187
+
1136
1188
  let deliveryProbeBusy = false
1137
1189
  const flushPendingReplies = async () => {
1138
1190
  if (stopping || deliveryProbeBusy) return
@@ -1489,21 +1541,57 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1489
1541
  const cycleId = `${journal.runId}:cycle:${++cycleSequence}`
1490
1542
  const control = { cycleId, outcome: 'queued', cancelled: false, runner: null, statusTargets: new Set(), enqueuedAt: performance.now() }
1491
1543
  const cycleData = { cycleId, kind, lane: laneName, model: kind === 'full' ? codeModel : liteModel, workdir: itemWorkdir || (laneName === 'work' ? workdir : ''), ...(taskRef ? { ticket: { projectId: taskRef.projectId, ticketId: taskRef.ticketId } } : {}), ...(delivery ? { thread: { channelId: delivery.channelId, threadId: delivery.parentId } } : {}) }
1544
+ liveCycles.set(cycleId, { data: cycleData, control, taskRef, delivery })
1492
1545
  emit('cycle.queued', { ...cycleData, status: 'queued' })
1493
1546
  return queues[laneName].enqueue({ kind, context, targetChannels, taskRef, delivery, workdir: itemWorkdir, control }, key).then((result) => {
1494
1547
  const status = control.cancelled || result?.status === 'canceled' ? 'canceled' : result?.status === 'failed' ? 'error' : control.outcome
1495
1548
  emit(status === 'canceled' ? 'cycle.cancelled' : 'cycle.finished', { ...cycleData, status, finishedBy: control.finishedBy || 'runtime', durationMs: Math.round(performance.now() - (control.startedAt ?? control.enqueuedAt)) })
1549
+ liveCycles.delete(cycleId)
1550
+ activity.clear(cycleId)
1496
1551
  return result
1497
1552
  })
1498
1553
  }
1499
1554
 
1555
+ async function executeRecoverableCycle(item, runner) {
1556
+ const { control } = item
1557
+ let partialOutput = ''
1558
+ for (let attempt = 1; !stopping && !control.cancelled; attempt++) {
1559
+ control.attempt = attempt
1560
+ let interruption
1561
+ const continuation = attempt === 1 ? '' : '\n\nTRANSPORT RECOVERY: The previous runtime was interrupted before an agent final response. Continue the original request in the existing workspace. Inspect current files, git state, and live task/thread history first; edits and remote writes may already have succeeded. Do not blindly repeat commits, ticket creation, comments, or other writes. Verify uncertain outcomes before acting. You decide the remaining work and when to finish.' + (partialOutput ? '\nPrevious partial output (not a final result):\n' + partialOutput : '')
1562
+ try {
1563
+ interruption = await executeCycle(item.kind, item.context + continuation, item.targetChannels, item.taskRef, item.delivery, runner, control, item.workdir)
1564
+ if (!interruption?.transportInterrupted) return
1565
+ partialOutput = String(interruption.outputText || partialOutput).slice(-12000)
1566
+ } catch (error) {
1567
+ if (stopping || control.cancelled) return
1568
+ if (attempt > recoveryDelays.length || !isTransportInterruption(error)) throw error
1569
+ }
1570
+ if (stopping || control.cancelled) return
1571
+ control.outcome = 'recovering'
1572
+ activity.clear(control.cycleId)
1573
+ const delayMs = recoveryDelays[attempt - 1]
1574
+ emit('cycle.recovering', { cycleId: control.cycleId, status: 'recovering', attempt, retryInMs: delayMs, reason: 'Runtime connection interrupted; preserving accepted work' })
1575
+ log(`coding runtime connection interrupted; resuming attempt ${attempt + 1} in ${delayMs / 1000}s or on reconnect`)
1576
+ await new Promise((resolve) => {
1577
+ const wake = () => { clearTimeout(timer); recoveryWaiters.delete(wake); control.wakeRecovery = null; resolve() }
1578
+ // A known event-channel outage can last longer than the retry budget.
1579
+ // Keep accepted work pending until reconnect instead of burning model
1580
+ // attempts while offline. Provider-only failures still use backoff.
1581
+ const timer = setTimeout(() => { if (eventConnection !== 'disconnected') wake() }, delayMs)
1582
+ recoveryWaiters.add(wake)
1583
+ control.wakeRecovery = wake
1584
+ })
1585
+ }
1586
+ }
1587
+
1500
1588
  async function executeCycle(kind, context, targetChannels = [], taskRef = null, delivery = null, runner, control, preparedWorkdir = '') {
1501
1589
  refreshModelSettings()
1502
1590
  const laneName = kind === 'full' ? 'work' : 'reply'
1503
1591
  const cycleControl = control || { cancelled: false, runner, statusTargets: new Set() }
1504
1592
  const cycleStartedAt = performance.now()
1505
- cycleControl.startedAt = cycleStartedAt
1506
- cycleControl.outcome = 'skipped'
1593
+ cycleControl.startedAt ??= cycleStartedAt
1594
+ cycleControl.outcome = 'preflight'
1507
1595
  emit('cycle.started', { cycleId: cycleControl.cycleId, kind, lane: laneName, status: 'preflight', model: kind === 'full' ? codeModel : liteModel, workdir: preparedWorkdir || (kind === 'full' ? workdir : ''), ...(taskRef ? { ticket: { projectId: taskRef.projectId, ticketId: taskRef.ticketId } } : {}), ...(delivery ? { thread: { channelId: delivery.channelId, threadId: delivery.parentId } } : {}), queueMs: Math.round(cycleStartedAt - (cycleControl.enqueuedAt ?? cycleStartedAt)) })
1508
1596
  log(`cycle timing queue=${Math.round(cycleStartedAt - (cycleControl.enqueuedAt ?? cycleStartedAt))}ms kind=${kind}`)
1509
1597
  const ctx = context ? [context] : []
@@ -1511,12 +1599,12 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1511
1599
  let verifiedTaskRevision = ''
1512
1600
  const ticketWorktree = preparedWorkdir || (kind === 'full' && activeTaskRef ? findTicketWorktree(workdir, activeTaskRef.ticketId) : '')
1513
1601
  const runnerOptions = {
1514
- cycleId: cycleControl.cycleId, attempt: 1,
1602
+ cycleId: cycleControl.cycleId, attempt: cycleControl.attempt || 1,
1515
1603
  ...(delivery?.watcherOwned ? { disabledMcpTools: ['post_message'] } : {}),
1516
1604
  ...(ticketWorktree ? { workdir: ticketWorktree } : {}),
1517
1605
  }
1518
1606
  const targets = [...new Set(targetChannels.filter((id) => id != null && Number.isFinite(Number(id))).map(Number))]
1519
- cycleControl.statusTargets = laneName === 'reply' ? replyStatusTargets : new Set(targets)
1607
+ cycleControl.statusTargets = new Set(targets)
1520
1608
  if (laneName === 'reply') {
1521
1609
  replyStatusTargets.clear()
1522
1610
  for (const target of targets) replyStatusTargets.add(target)
@@ -1539,8 +1627,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1539
1627
  const cycleModelRevision = modelSettingsRevision
1540
1628
  const useModel = kind === 'full' ? codeModel : liteModel
1541
1629
  log('running ' + kind + ' cycle…' + (ctx.length ? ' (' + ctx.length + ' event' + (ctx.length === 1 ? '' : 's') + ')' : '') + (useModel ? ' [' + useModel + ']' : ''))
1542
- // Presence notifications are intentionally disabled; durable messages and
1543
- // ticket transitions are the only user-visible progress signals.
1630
+ activity.set(cycleControl.cycleId, cycleControl.statusTargets, 'thinking')
1544
1631
  try {
1545
1632
  // The ticket may have been reassigned or handed to review while waiting.
1546
1633
  // Re-check at dequeue, before any model can edit the repository.
@@ -1548,19 +1635,23 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1548
1635
  const data = toolData(await callMcpTool('get_ticket', { project_id: activeTaskRef.projectId, ticket_id: activeTaskRef.ticketId }))
1549
1636
  const ticket = data.ticket ?? data.task ?? data
1550
1637
  verifiedTaskRevision = taskRevision(ticket)
1638
+ cycleControl.taskLabel = ticketDisplaySlug(ticket) || String(ticket.title || 'the task').slice(0, 160)
1551
1639
  const typeSets = await taskTypeSets(activeTaskRef.projectId)
1552
1640
  if (blockedTasks.has(`${activeTaskRef.projectId}:${activeTaskRef.ticketId}`) ||
1553
1641
  !taskBelongsToAgent(ticket, { id: selfAgentId, identifier }) ||
1554
1642
  taskIsCompleted(ticket, typeSets.done) || taskIsAwaitingReview(ticket, typeSets.review)) {
1555
1643
  releaseTaskForRetry(activeTaskRef, prompt)
1644
+ cycleControl.outcome = 'skipped'
1556
1645
  log('queued ticket no longer actionable; skipped before model start')
1557
1646
  return
1558
1647
  }
1559
1648
  }
1560
1649
  if (cycleControl.cancelled) return
1650
+ cycleControl.planKey ||= 'plan:' + (delivery?.key || `${activeTaskRef?.projectId}:${activeTaskRef?.ticketId}:${verifiedTaskRevision}`)
1561
1651
  cycleControl.outcome = 'running'
1562
1652
  log(`cycle timing preflight=${Math.round(performance.now() - cycleStartedAt)}ms kind=${kind}`)
1563
1653
  const result = await runner.runCycle(prompt, useModel, runnerOptions)
1654
+ await cycleControl.planDelivery
1564
1655
  cycleControl.outcome = result?.is_error ? 'error' : result?.subtype || 'error'
1565
1656
  if (result?.timings) log(`cycle timing runtime=${JSON.stringify(result.timings)} kind=${kind}`)
1566
1657
  refreshModelSettings()
@@ -1584,6 +1675,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1584
1675
  return
1585
1676
  }
1586
1677
  if (!cycleSucceeded(result)) {
1678
+ if (kind === 'full' && interruptedRuntimeResult(result) && cycleControl.attempt <= recoveryDelays.length) return { ...result, transportInterrupted: true }
1587
1679
  const outcome = result?.subtype || 'an unknown runtime error'
1588
1680
  const notice = `I'm blocked because the ${kind === 'full' ? 'coding' : 'reply'} cycle ended with ${outcome}.${result?.subtype === 'rate_limited' ? ` The model provider is rate-limiting this request. Choose a model with available quota in Agent Studio, or retry once the limit resets.\n\n[Open Agent Studio](http://127.0.0.1:4317/#agent=${encodeURIComponent(identifier)}&settings=1)\n\n` : ' '}I'm not claiming completion.${activeTaskRef ? " I've paused this revision until the ticket changes." : ''}`
1589
1681
  log('WORK_CYCLE_BLOCKED ' + outcome + '; publishing blocker')
@@ -1640,6 +1732,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1640
1732
  }
1641
1733
  }
1642
1734
  } finally {
1735
+ activity.clear(cycleControl.cycleId)
1643
1736
  cycleControl.statusTargets.clear()
1644
1737
  }
1645
1738
  }
@@ -1702,6 +1795,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1702
1795
  for (const [name, queue] of Object.entries(queues)) {
1703
1796
  queue.cancel((item) => String(item.taskRef?.projectId) === String(projectId) && String(item.taskRef?.ticketId) === String(ticketId), (item) => {
1704
1797
  item.control.cancelled = true
1798
+ item.control.wakeRecovery?.()
1705
1799
  void item.control.runner?.cancelCurrent?.()
1706
1800
  })
1707
1801
  }
@@ -1870,10 +1964,10 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1870
1964
  try { contextService.observe({ name: 'list_message_thread', args: { channel_id: cid, message_id: threadRoot }, result: { ...msg, id: mid, content: String(msg.content || msg.body || msg.text || '') } }) }
1871
1965
  catch { /* optional conversation indexing */ }
1872
1966
  // Every runtime carries the source thread through the queue so a later
1873
- // redirect can cancel its process. Codex additionally delegates the final
1874
- // post to the watcher for an authoritative last-moment thread check.
1967
+ // redirect can cancel its process. The watcher delivers the final answer
1968
+ // for every runtime after an authoritative last-moment thread check.
1875
1969
  const conversationDelivery = (stage = 'reply', skipIfAnyAgentReply = stage !== 'result') => cid != null
1876
- ? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey, sourceMessageId: mid, watcherOwned: agent === 'codex' }
1970
+ ? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey, sourceMessageId: mid, watcherOwned: true }
1877
1971
  : null
1878
1972
  // Under-the-hood model control from chat (view / switch the model the agent runs).
1879
1973
  const mcmd = cid != null ? parseModelCmd(text) : null
@@ -1958,7 +2052,19 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1958
2052
  // recover channel mentions even when the backend omits targeted dispatch.
1959
2053
  // Both event types share recipient, ownership and deduplication guards.
1960
2054
  log('up — backend WS watcher on ' + wsUrl + (canCode ? ' [code: ' + workdir + ']' : ''))
1961
- handle = (dependencies.connect || connectAgentWs)({ wsUrl, apiKey, identifier, onEvent, onConnect: () => { if (autoStart) setTimeout(() => { if (!stopping) void reconcileBacklog() }, 250) }, log })
2055
+ handle = (dependencies.connect || connectAgentWs)({ wsUrl, apiKey, identifier, onEvent,
2056
+ onDisconnect: () => {
2057
+ eventConnection = 'disconnected'
2058
+ emit('connection.disconnected', { status: 'disconnected', reason: 'Event connection lost; accepted work remains running' })
2059
+ },
2060
+ onConnect: () => {
2061
+ if (stopping) return
2062
+ eventConnection = 'connected'
2063
+ activity.pulse()
2064
+ emit('connection.connected', { status: 'connected' })
2065
+ for (const wake of [...recoveryWaiters]) wake()
2066
+ if (autoStart) setTimeout(() => { if (!stopping) void reconcileBacklog() }, 250)
2067
+ }, log })
1962
2068
 
1963
2069
  let authorizationProbeBusy = false
1964
2070
  const legacyAuthorizationChecks = new Set()
@@ -1999,7 +2105,14 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1999
2105
  const modelSettingsTimer = autoStart ? setInterval(refreshModelSettings, 2_000) : null
2000
2106
  modelSettingsTimer?.unref?.()
2001
2107
  emit('models.updated', { model: codeModel, chatModel: liteModel, modelSettingsRevision })
2002
- const heartbeatTimer = autoStart ? setInterval(() => emit('watcher.heartbeat', { status: 'running', active: queues.work.activeSize + queues.reply.activeSize, pending: queues.work.size + queues.reply.size, model: codeModel, chatModel: liteModel, modelSettingsRevision, workdir: workdir || '' }), 15_000) : null
2108
+ const heartbeatTimer = autoStart ? setInterval(() => {
2109
+ emit('watcher.heartbeat', { status: 'running', active: queues.work.activeSize + queues.reply.activeSize, pending: queues.work.size + queues.reply.size, model: codeModel, chatModel: liteModel, modelSettingsRevision, workdir: workdir || '' })
2110
+ // The Studio reads a bounded journal tail. Re-state current queue ownership
2111
+ // so a long-running cycle stays visible after its start event rotates out.
2112
+ for (const { data, control } of liveCycles.values()) {
2113
+ if (!control.cancelled) emit('cycle.snapshot', { ...data, status: control.outcome, attempt: control.attempt || 1 })
2114
+ }
2115
+ }, 15_000) : null
2003
2116
  heartbeatTimer?.unref?.()
2004
2117
  let introTimer = null, taskProbeStartTimer = null, taskProbeTimer = null
2005
2118
  if (autoStart && (mcpConfig || mcpUrl)) {
@@ -2039,12 +2152,13 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
2039
2152
  if (taskProbeTimer) clearInterval(taskProbeTimer)
2040
2153
  try { handle?.close() } catch { /* already disconnected */ }
2041
2154
  for (const queue of Object.values(queues)) {
2042
- queue.cancel(() => true, (item) => { item.control.cancelled = true })
2155
+ queue.cancel(() => true, (item) => { item.control.cancelled = true; item.control.wakeRecovery?.() })
2043
2156
  }
2044
2157
  await Promise.allSettled([...activeReplyRunners, ...activeWorkRunners].map((runner) => Promise.resolve().then(() => runner.cancelCurrent?.())))
2045
2158
  await Promise.allSettled([...mentionIntake.values(), ...Object.values(queues).map((queue) => queue.settled()), ...messageDeliveries.values(), ...pendingOperations])
2046
2159
  workRunners.close()
2047
2160
  replyRunners.close()
2161
+ await activity.close()
2048
2162
  try { await contextService.close() } catch (error) { log('context search shutdown failed: ' + (error?.message || error)) }
2049
2163
  try { await memory.settled?.() }
2050
2164
  catch (error) { log('memory shutdown failed: ' + (error?.message || error)) }
package/src/ws.mjs CHANGED
@@ -37,7 +37,7 @@ export function assertWebSocket(fail) {
37
37
  * @param {(kind: string, payload: any) => void} o.onEvent targeted dispatch.
38
38
  * @param {(msg: string) => void} o.log
39
39
  */
40
- export function connectAgentWs({ wsUrl, apiKey, identifier, onEvent, onConnect, log }) {
40
+ export function connectAgentWs({ wsUrl, apiKey, identifier, onEvent, onConnect, onDisconnect, log }) {
41
41
  const base = stripSlash(wsUrl)
42
42
  const url = `${base}?api_key=${encodeURIComponent(apiKey)}&identifier=${encodeURIComponent(identifier)}`
43
43
 
@@ -96,10 +96,11 @@ export function connectAgentWs({ wsUrl, apiKey, identifier, onEvent, onConnect,
96
96
  })
97
97
 
98
98
  const down = (why) => {
99
- if (sock !== ws) return
99
+ if (closed || sock !== ws) return
100
100
  clearKeepalive()
101
101
  ws = null
102
102
  log(why)
103
+ try { onDisconnect?.() } catch { /* connection telemetry is optional */ }
103
104
  scheduleReconnect()
104
105
  }
105
106
  // $connect 401 (bad/inactive credentials) surfaces as an immediate close.
package/studio/app.mjs CHANGED
@@ -82,7 +82,7 @@ export function deriveModel(input) {
82
82
  if (event.type.startsWith('cycle.')) {
83
83
  if (data.kind != null) cycle.data.kind = data.kind;
84
84
  cycle.status = eventStatus(event);
85
- if (event.type === 'cycle.started') cycle.startedAt = event.timestamp;
85
+ if (event.type === 'cycle.started') cycle.startedAt ||= event.timestamp;
86
86
  if (terminalStatuses.has(cycle.status)) cycle.finishedAt = event.timestamp;
87
87
  }
88
88
  if (event.type.startsWith('tool.')) {
package/studio/style.css CHANGED
@@ -45,6 +45,8 @@
45
45
  button:disabled { cursor: default; opacity: .6; }
46
46
  button, a, input, select, summary { touch-action: manipulation; }
47
47
  :focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; }
48
+ input:focus, select:focus, textarea:focus { outline: none; }
49
+ input:focus-visible, select:focus-visible, textarea:focus-visible { box-shadow: inset 0 -2px 0 var(--focus); }
48
50
  button, select, a { transition: background-color .16s ease-out, color .16s ease-out, box-shadow .16s ease-out; }
49
51
  h1, h2, h3, h4, p { margin: 0; }
50
52
  h1, h2, h3, h4, strong { font-weight: 500; }
@@ -76,7 +78,7 @@
76
78
  .count { font-size: 12px; background: var(--stage); min-width: 22px; height: 19px; display: grid; place-items: center; border-radius: 999px; font-variant-numeric: tabular-nums; }
77
79
  .agent-search { display: flex; align-items: center; gap: 7px; padding: 8px 10px; margin: 0 0 12px; color: var(--subtle); }
78
80
  .agent-search .icon { width: 14px; height: 14px; }
79
- .agent-search input { width: 100%; border: 0; outline-offset: 5px; background: transparent; font-size: 12px; }
81
+ .agent-search input { width: 100%; border: 0; background: transparent; font-size: 12px; }
80
82
  .agent-list { display: flex; flex-direction: column; gap: 5px; }
81
83
  .agent-item { display: flex; align-items: center; gap: 10px; width: 100%; text-align: left; border: 0; background: transparent; padding: 10px; border-radius: var(--radius-md); corner-shape: squircle; }
82
84
  .agent-item:hover { background: var(--hover); }
@@ -123,7 +125,7 @@
123
125
  .filters { display: flex; gap: 9px; margin-bottom: 19px; }
124
126
  .search-field { flex: 1; min-width: 90px; display: flex; align-items: center; gap: 9px; border: 1px solid var(--border); border-radius: 999px; padding: 8px 12px; color: var(--subtle); }
125
127
  .search-field .icon { width: 14px; height: 14px; }
126
- .search-field input { width: 100%; background: transparent; border: 0; font-size: 12px; outline-offset: 4px; }
128
+ .search-field input { width: 100%; background: transparent; border: 0; font-size: 12px; }
127
129
  .select-field { display: inline-flex; align-items: center; min-width: 108px; }
128
130
  .select-field select { width: 100%; min-height: 40px; padding: 7px 26px 7px 12px; border: 1px solid var(--border); border-radius: 999px; background-color: var(--surface); font-size: 12px; }
129
131
  .content-grid { display: grid; grid-template-columns: minmax(280px, .85fr) minmax(360px, 1.15fr); gap: 14px; align-items: start; }
@@ -360,7 +362,6 @@
360
362
  .model-settings-form label { display: grid; gap: 4px; font-size: 13px; margin-top: 8px; }
361
363
  .model-settings-form label span { font-size: 12px; color: var(--muted); }
362
364
  .model-settings-form input { width: 100%; min-width: 0; padding: 11px 14px; border: 1px solid var(--border); border-radius: 18px; background: transparent; color: var(--ink); font-size: 13px; }
363
- .model-settings-form input:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
364
365
  .model-settings-actions { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px; margin-top: 8px; }
365
366
  #model-settings-error { color: #9f3030; }
366
367
  @media (max-width: 640px) {