openvisio-agent 0.23.0 → 0.24.1

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,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.24.1] — 2026-09-10
4
+
5
+ - Deliver agent-authored acknowledgements and short plans to the originating channel or thread when a request moves into a coding workspace.
6
+ - Highlight the selected agent in Studio and show embedded avatars shared with the main app.
7
+
8
+ ## [0.24.0] — 2026-09-10
9
+
10
+ - 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.
11
+ - Enable native coding delegation through Codex multi-agent, OpenCode task, and Claude Agent/Task, with scoped child objectives and parent review.
12
+ - Restore working, thinking, and typing presence through the documented REST endpoint, with per-cycle ownership, throttled refresh, and failure backoff.
13
+ - Remove Studio input outlines and retain a subtle keyboard-focus underline.
14
+ - 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.
15
+ - 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.
16
+ - Refresh active cycle state in the Studio journal so long tasks remain visible after their start event leaves the bounded activity window.
17
+
3
18
  ## [0.23.0] — 2026-09-10
4
19
 
5
20
  - 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,10 @@ 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
+
81
+ When a conversation turns into coding work, the agent includes its acknowledgement and two or three next steps in the workspace handoff. They are posted to the original channel thread before coding starts. Private continuation context stays internal, and the work session does not send a second pickup message.
82
+
79
83
  ## Open Agent Studio
80
84
 
81
85
  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.1",
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
@@ -4,18 +4,25 @@ export function runtimeControlTools({ canCode = false, workspaceAvailable = fals
4
4
  if (canCode || !workspaceAvailable) return []
5
5
  return [{
6
6
  name: WORK_SESSION_TOOL,
7
- description: 'Continue this same request in your configured coding workspace. Use when investigation reveals that local execution or edits are needed. You remain the same agent and retain the original task, recipient, and permissions. Supply the context worth carrying forward, then end this turn; the watcher starts your work session and delivers its eventual response. Do not ask the user to reassign the task.',
8
- inputSchema: { type: 'object', properties: { context: { type: 'string', minLength: 1, maxLength: 12000, description: 'Relevant findings, decisions, remaining work, and context for your continuation.' } }, required: ['context'], additionalProperties: false },
7
+ description: 'Continue this same request in your configured coding workspace. Use when investigation reveals that local execution or edits are needed. You remain the same agent and retain the original task, recipient, and permissions. Supply private continuation context plus a teammate-facing acknowledgement and 2-3 concrete plan steps. The watcher posts that acknowledgement and plan in the original channel/thread before starting your work session, then delivers your eventual result separately. End this turn after requesting continuation; do not repeat the acknowledgement or ask for reassignment.',
8
+ inputSchema: { type: 'object', properties: {
9
+ context: { type: 'string', minLength: 1, maxLength: 12000, description: 'Private findings, decisions, remaining work, and context for your continuation. This field is not posted to chat.' },
10
+ acknowledgement: { type: 'string', minLength: 1, maxLength: 1200, description: 'A short, natural first-person acknowledgement for the teammate. Say what you have accepted; do not claim completion.' },
11
+ plan: { type: 'array', minItems: 2, maxItems: 3, items: { type: 'string', minLength: 1, maxLength: 240 }, description: '2-3 concrete next steps to post alongside your acknowledgement before doing the work.' },
12
+ }, required: ['context', 'acknowledgement', 'plan'], additionalProperties: false },
9
13
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
10
14
  }]
11
15
  }
12
16
 
13
17
  export function requestWorkSession(args, capabilities) {
14
18
  if (!runtimeControlTools(capabilities).length) throw new Error('No additional coding workspace is available in this session')
15
- if (!args || Object.keys(args).some((key) => key !== 'context') || typeof args.context !== 'string' || !args.context.trim() || args.context.length > 12000) throw new Error('Provide only a nonempty context string of at most 12000 characters')
16
- return { content: [{ type: 'text', text: JSON.stringify({ openvisioControl: { action: 'request_work_session', context: args.context.trim() } }) }] }
19
+ if (!args || Object.keys(args).some((key) => !['context', 'acknowledgement', 'plan'].includes(key)) || typeof args.context !== 'string' || !args.context.trim() || args.context.length > 12000) throw new Error('Provide a nonempty context string of at most 12000 characters and only the advertised acknowledgement and plan fields')
20
+ if (typeof args.acknowledgement !== 'string' || !args.acknowledgement.trim() || args.acknowledgement.length > 1200 || !validPlan(args.plan)) throw new Error('Include a short acknowledgement and 2-3 nonempty plan steps of at most 240 characters each for delivery to the original conversation')
21
+ return { content: [{ type: 'text', text: JSON.stringify({ openvisioControl: { action: 'request_work_session', context: args.context.trim(), acknowledgement: args.acknowledgement.trim(), plan: args.plan.map(step => step.trim()) } }) }] }
17
22
  }
18
23
 
24
+ const validPlan = (plan) => Array.isArray(plan) && plan.length >= 2 && plan.length <= 3 && plan.every(step => typeof step === 'string' && step.trim() && step.length <= 240)
25
+
19
26
  // Only inspect outputs of our named local control tool, never arbitrary model
20
27
  // prose, commands, ticket text, or repository content.
21
28
  export function workSessionRequest(output, depth = 0) {
@@ -28,7 +35,11 @@ export function workSessionRequest(output, depth = 0) {
28
35
  return null
29
36
  }
30
37
  const request = output.openvisioControl
31
- if (request?.action === 'request_work_session' && typeof request.context === 'string' && request.context.trim() && request.context.length <= 12000) return { context: request.context.trim() }
38
+ if (request?.action === 'request_work_session' && typeof request.context === 'string' && request.context.trim() && request.context.length <= 12000) return {
39
+ context: request.context.trim(),
40
+ ...(typeof request.acknowledgement === 'string' && request.acknowledgement.trim() && request.acknowledgement.length <= 1200 ? { acknowledgement: request.acknowledgement.trim() } : {}),
41
+ ...(validPlan(request.plan) ? { plan: request.plan.map(step => step.trim()) } : {}),
42
+ }
32
43
  for (const key of ['content', 'text', 'result', 'structuredContent']) {
33
44
  const nested = workSessionRequest(output[key], depth + 1)
34
45
  if (nested) return nested
@@ -105,14 +105,19 @@ export async function readStudioSnapshot({ stateDir, now = Date.now }) {
105
105
  const age = now() - Date.parse(event.timestamp)
106
106
  const online = age >= -5000 && age <= FRESH_MS && event.type !== 'runtime.stopped' && pidAlive(event.pid)
107
107
  const existing = agentsById.get(event.agent.identifier)
108
- agentsById.set(event.agent.identifier, { ...existing, id: event.agent.identifier, identifier: event.agent.identifier, slug: event.agent.slug || event.agent.identifier, name: existing?.name || event.agent.slug || event.agent.identifier, provider: event.agent.provider || 'unknown', runId: event.runId, pid: Number.isSafeInteger(event.pid) ? event.pid : null, watcherAlive: online, status: online ? 'online' : 'offline', lastSeen: event.timestamp, lastEventType: event.type })
108
+ agentsById.set(event.agent.identifier, { ...existing, id: event.agent.identifier, identifier: event.agent.identifier, slug: existing?.slug || event.agent.slug || event.agent.identifier, name: existing?.name || event.agent.slug || event.agent.identifier, provider: event.agent.provider || 'unknown', runId: event.runId, pid: Number.isSafeInteger(event.pid) ? event.pid : null, watcherAlive: online, status: online ? 'online' : 'offline', lastSeen: event.timestamp, lastEventType: event.type })
109
109
  }
110
- const agents = [...agentsById.values()].sort((a, b) => a.name.localeCompare(b.name))
110
+ const agents = [...agentsById.values()]
111
111
  for (const agent of agents) {
112
+ const profileEvent = events.findLast(event => event.agent.identifier === agent.identifier && (event.type === 'agent.profile' || event.type === 'watcher.heartbeat' && event.data?.profile))
113
+ const profile = profileEvent?.type === 'agent.profile' ? profileEvent.data : profileEvent?.data?.profile
114
+ if (typeof profile?.name === 'string' && profile.name.trim()) agent.name = profile.name.trim()
115
+ if (typeof profile?.avatarSeed === 'string' && profile.avatarSeed) agent.avatarSeed = profile.avatarSeed
112
116
  const applied = events.findLast(event => event.agent.identifier === agent.identifier && event.runId === agent.runId && typeof event.data?.modelSettingsRevision === 'string')
113
117
  agent.appliedModelRevision = applied?.data.modelSettingsRevision || ''
114
118
  agent.modelControlsSupported = !!applied
115
119
  }
120
+ agents.sort((a, b) => a.name.localeCompare(b.name))
116
121
  return { schemaVersion: 1, demo: false, generatedAt: new Date(now()).toISOString(), agents, events: events.slice(-MAX_EVENTS), limits: { maxEvents: MAX_EVENTS, maxFiles: MAX_FILES, maxTotalBytes: MAX_TOTAL_BYTES }, warnings }
117
122
  }
118
123
 
@@ -219,7 +224,7 @@ export async function startStudioServer({ stateDir, host = '127.0.0.1', port = 4
219
224
  sendSnapshot(res, await snapshot())
220
225
  return
221
226
  }
222
- const file = ({ '/': 'index.html', '/index.html': 'index.html', '/guide': 'guide.html', '/guide.html': 'guide.html', '/style.css': 'style.css', '/app.mjs': 'app.mjs', '/satoshi-400.woff2': 'satoshi-400.woff2', '/satoshi-500.woff2': 'satoshi-500.woff2', '/openvisio.svg': 'openvisio.svg' })[path]
227
+ const file = ({ '/': 'index.html', '/index.html': 'index.html', '/guide': 'guide.html', '/guide.html': 'guide.html', '/style.css': 'style.css', '/app.mjs': 'app.mjs', '/bot-avatar.mjs': 'bot-avatar.mjs', '/satoshi-400.woff2': 'satoshi-400.woff2', '/satoshi-500.woff2': 'satoshi-500.woff2', '/openvisio.svg': 'openvisio.svg' })[path]
223
228
  if (!file) { res.writeHead(404); res.end('Not found'); return }
224
229
  const target = await realpath(resolve(root, file))
225
230
  const rel = relative(root, target)
@@ -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,26 @@ 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
+ // Reply sessions may discover work themselves. Keep their native plan for
634
+ // an explicit continuation, without announcing plans for ordinary answers.
635
+ if (type === 'plan.updated') live.control.planEntries = data.entries
636
+ if (type === 'plan.updated' && live.data.kind === 'full' && data.entries?.some(entry => typeof entry?.content === 'string' && entry.content.trim()) && !live.control.planDelivery) {
637
+ live.control.planDelivery = trackOperation(publishWorkPlan(live, data.entries)).catch(error => log('Task plan delivery unavailable: ' + (error?.message || error)))
638
+ }
639
+ }
611
640
  const canCode = !!workdir
612
641
  // The Mastra bridge authenticates per-CALL: every openvisio-team tool needs
613
642
  // agent_identifier + agent_api_key as arguments. Hand them over up front.
@@ -618,7 +647,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
618
647
  const systemPrompt = (canCode ? CODE_CHARTER : CHAT_CHARTER) + '\n\n' + credNote
619
648
  const runnerOptions = {
620
649
  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,
650
+ cfgKey: identifier, mcpConfig, workdir, workspaceAvailable: canCode, log, debug, model, systemPrompt, onEvent: runtimeEvent,
622
651
  getContextEnvironment: () => contextService.environment(),
623
652
  }
624
653
  // One watcher and one WS subscription, with a bounded pool of isolated coding
@@ -626,6 +655,9 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
626
655
  // concurrently behind the same delivery guards.
627
656
  const MAX_CONCURRENT_WORKERS = 3
628
657
  const activeWorkRunners = new Set()
658
+ const recoveryWaiters = new Set()
659
+ const recoveryDelays = dependencies.transportRecoveryDelays || transportRecoveryDelays
660
+ let eventConnection = 'unknown'
629
661
  const createWorkRunner = (control, workerWorkdir = '') => makeRunner({
630
662
  ...runnerOptions,
631
663
  ...(workerWorkdir ? { workdir: workerWorkdir } : {}),
@@ -652,7 +684,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
652
684
  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
685
  const fastPrompt = (canCode ? CODE_FAST : CYCLE_FAST) + '\n\n' + backendToolRule + studioGuide
654
686
  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
687
+ 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
688
  // Live model state — changeable at runtime by the in-chat `/model` command.
657
689
  // codeModel drives full/sweep cycles; chatModel (if set) the lighter fast/intro
658
690
  // ones, so routine chatter can run cheaper than real code work.
@@ -681,7 +713,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
681
713
  const runner = workRunners.acquire(sessionKey, { control: item.control, workdir: item.workdir })
682
714
  item.control.runner = runner
683
715
  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)) }
716
+ try { return await cycleContext.run({ cycleId: item.control.cycleId }, () => executeRecoverableCycle(item, runner)) }
685
717
  finally { workRunners.release(sessionKey); activeWorkRunners.delete(runner); item.control.runner = null }
686
718
  },
687
719
  onError: (error, item) => {
@@ -743,7 +775,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
743
775
  const finishedTaskVersions = new Map(Array.isArray(replayState.finishedTaskVersions) ? replayState.finishedTaskVersions : [])
744
776
  const reportedCompletions = new Set(Array.isArray(replayState.reportedCompletions) ? replayState.reportedCompletions : [])
745
777
  const reportedTaskComments = new Set(Array.isArray(replayState.reportedTaskComments) ? replayState.reportedTaskComments : [])
746
- // Transport/runtime failures are held at the ticket revision that produced them.
778
+ // Unrecoverable runtime failures are held at the ticket revision that produced them.
747
779
  // This is distinct from a policy block: any later human ticket change resumes
748
780
  // the work, but reconnects and the watcher's own blocker update do not.
749
781
  const failedTaskVersions = new Map(replayState.completionPolicy === 'agent' && Array.isArray(replayState.failedTaskVersions) ? replayState.failedTaskVersions : [])
@@ -849,13 +881,17 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
849
881
  let selfAgentId = null
850
882
  let selfOrganizationId = null
851
883
  let identityContext = memory.get?.(`identity:${identifier}`)?.meta?.context || agentProfileContext({}, identifier)
884
+ let agentAppearance = memory.get?.(`identity:${identifier}`)?.meta?.appearance || null
885
+ if (agentAppearance) emit('agent.profile', agentAppearance)
852
886
  const selfAliases = new Set([slug, identifier].map((value) => String(value || '').toLowerCase()).filter(Boolean))
853
887
  const rememberSelfAgent = (self) => {
854
888
  if (!self || typeof self !== 'object' || String(self.identifier || self.slug || '') !== identifier) return
855
889
  if (validBackendId(self.id)) selfAgentId = Number(self.id)
856
890
  for (const alias of [self.name, self.identifier, self.slug]) if (alias) selfAliases.add(String(alias).toLowerCase())
857
891
  identityContext = agentProfileContext(self, identifier)
858
- memory.remember({ key: `identity:${identifier}`, kind: 'identity', state: 'configured', summary: self.name || identifier, meta: { context: identityContext } })
892
+ const appearance = { name: String(self.name || identifier).slice(0, 160), avatarSeed: String(self.slug || self.identifier || identifier).slice(0, 240) }
893
+ if (JSON.stringify(appearance) !== JSON.stringify(agentAppearance)) { agentAppearance = appearance; emit('agent.profile', appearance) }
894
+ memory.remember({ key: `identity:${identifier}`, kind: 'identity', state: 'configured', summary: self.name || identifier, meta: { context: identityContext, appearance } })
859
895
  const organizationId = self.organization_id ?? self.organizationId
860
896
  if (validBackendId(organizationId)) {
861
897
  selfOrganizationId = Number(organizationId)
@@ -1030,6 +1066,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1030
1066
  for (const [laneName, queue] of Object.entries(queues)) {
1031
1067
  queue.cancel((item) => sameDeliveryThread(item.delivery, channelId, parentId), (item) => {
1032
1068
  item.control.cancelled = true
1069
+ item.control.wakeRecovery?.()
1033
1070
  log(`${laneName} lane cancelled by a newer redirect/stand-down in thread ${parentId}`)
1034
1071
  void item.control.runner?.cancelCurrent?.('thread-cancelled')
1035
1072
  })
@@ -1058,7 +1095,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1058
1095
  identity: () => ({ id: selfAgentId, identifier, slug, name: slug }),
1059
1096
  load: async (cid, root) => toolData(await callMcpTool('list_message_thread', { channel_id: Number(cid), message_id: Number(root) })),
1060
1097
  })
1061
- const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '', sourceMessageId, allowCancelled = false }) => {
1098
+ const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '', sourceMessageId, allowCancelled = false, isCancelled = () => false }) => {
1062
1099
  if (stopping) throw new Error('Watcher is stopping')
1063
1100
  const deliveryKey = String(key || '')
1064
1101
  if (parentId == null) {
@@ -1075,6 +1112,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1075
1112
  if (messageDeliveries.has(deliveryKey)) return messageDeliveries.get(deliveryKey)
1076
1113
  const controlKey = threadControlKey(channelId, parentId)
1077
1114
  const suppressCancelled = () => {
1115
+ if (isCancelled()) return { posted: false, reason: 'cycle-cancelled' }
1078
1116
  if (memory.has(deliveryKey, 'suppressed')) return { posted: false, reason: 'source-cancelled' }
1079
1117
  if (allowCancelled || !controlKey || !memory.has(controlKey, 'cancelled')) return null
1080
1118
  pendingReplyDeliveries.delete(deliveryKey)
@@ -1094,7 +1132,8 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1094
1132
  const run = (async () => {
1095
1133
  if (parentId != null) {
1096
1134
  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)
1135
+ const plans = skipIfAnyAgentReply ? memory.recall({ channelId: Number(channelId), threadId: Number(parentId) }, 1000).filter(node => node.kind === 'work-plan' && node.meta?.sourceKey === sourceKey) : []
1136
+ const rendered = repliesAfterSource(renderedAgentMessages(live, { id: selfAgentId, identifier, slug, name: slug }), sourceMessageId).filter(row => !plans.some(plan => normalizeRenderedMessageText(plan.summary) === normalizeRenderedMessageText(row.content)))
1098
1137
  const duplicate = rendered.some((row) => normalizeRenderedMessageText(row.content) === normalizeRenderedMessageText(message))
1099
1138
  if (duplicate || (skipIfAnyAgentReply && rendered.length)) {
1100
1139
  deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
@@ -1133,6 +1172,27 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1133
1172
  return run
1134
1173
  }
1135
1174
 
1175
+ async function publishWorkPlan(live, entries, acknowledgement = '') {
1176
+ const { control, delivery, taskRef } = live
1177
+ 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))
1178
+ if (!steps.length || control.cancelled || !control.planKey || memory.has(control.planKey)) return
1179
+ const introduction = String(acknowledgement || '').trim().slice(0, 1200) || (control.taskLabel ? `I'm on ${control.taskLabel}.` : "I'll take this on.")
1180
+ const content = `${introduction}\n\n${steps.map((step, index) => `${index + 1}. ${step}`).join('\n')}`
1181
+ const channelId = delivery?.channelId ?? taskRef?.channelId ?? await projectStatusChannel(taskRef?.projectId)
1182
+ if (channelId == null || control.cancelled) return
1183
+ const refs = { channelId: Number(channelId), ...(delivery?.parentId != null ? { threadId: Number(delivery.parentId) } : {}) }
1184
+ // Remember intent before a potentially ambiguous write. A later final reply
1185
+ // must not be mistaken for a duplicate of this plan, even after reconnect.
1186
+ memory.remember({ key: control.planKey, kind: 'work-plan', state: 'attempted', summary: content, refs, meta: { sourceKey: delivery?.sourceKey || '' } })
1187
+ try {
1188
+ await postMessageOnce({ key: control.planKey + ':delivery', channelId, parentId: delivery?.parentId, projectId: taskRef?.projectId, sourceKey: delivery?.sourceKey, sourceMessageId: delivery?.sourceMessageId, content, isCancelled: () => control.cancelled })
1189
+ } finally {
1190
+ // An acknowledgement is only useful before work completes. Never leave a
1191
+ // delayed pickup message queued behind a delivered final response.
1192
+ pendingReplyDeliveries.delete(control.planKey + ':delivery'); persistReplay()
1193
+ }
1194
+ }
1195
+
1136
1196
  let deliveryProbeBusy = false
1137
1197
  const flushPendingReplies = async () => {
1138
1198
  if (stopping || deliveryProbeBusy) return
@@ -1489,21 +1549,57 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1489
1549
  const cycleId = `${journal.runId}:cycle:${++cycleSequence}`
1490
1550
  const control = { cycleId, outcome: 'queued', cancelled: false, runner: null, statusTargets: new Set(), enqueuedAt: performance.now() }
1491
1551
  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 } } : {}) }
1552
+ liveCycles.set(cycleId, { data: cycleData, control, taskRef, delivery })
1492
1553
  emit('cycle.queued', { ...cycleData, status: 'queued' })
1493
1554
  return queues[laneName].enqueue({ kind, context, targetChannels, taskRef, delivery, workdir: itemWorkdir, control }, key).then((result) => {
1494
1555
  const status = control.cancelled || result?.status === 'canceled' ? 'canceled' : result?.status === 'failed' ? 'error' : control.outcome
1495
1556
  emit(status === 'canceled' ? 'cycle.cancelled' : 'cycle.finished', { ...cycleData, status, finishedBy: control.finishedBy || 'runtime', durationMs: Math.round(performance.now() - (control.startedAt ?? control.enqueuedAt)) })
1557
+ liveCycles.delete(cycleId)
1558
+ activity.clear(cycleId)
1496
1559
  return result
1497
1560
  })
1498
1561
  }
1499
1562
 
1563
+ async function executeRecoverableCycle(item, runner) {
1564
+ const { control } = item
1565
+ let partialOutput = ''
1566
+ for (let attempt = 1; !stopping && !control.cancelled; attempt++) {
1567
+ control.attempt = attempt
1568
+ let interruption
1569
+ 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 : '')
1570
+ try {
1571
+ interruption = await executeCycle(item.kind, item.context + continuation, item.targetChannels, item.taskRef, item.delivery, runner, control, item.workdir)
1572
+ if (!interruption?.transportInterrupted) return
1573
+ partialOutput = String(interruption.outputText || partialOutput).slice(-12000)
1574
+ } catch (error) {
1575
+ if (stopping || control.cancelled) return
1576
+ if (attempt > recoveryDelays.length || !isTransportInterruption(error)) throw error
1577
+ }
1578
+ if (stopping || control.cancelled) return
1579
+ control.outcome = 'recovering'
1580
+ activity.clear(control.cycleId)
1581
+ const delayMs = recoveryDelays[attempt - 1]
1582
+ emit('cycle.recovering', { cycleId: control.cycleId, status: 'recovering', attempt, retryInMs: delayMs, reason: 'Runtime connection interrupted; preserving accepted work' })
1583
+ log(`coding runtime connection interrupted; resuming attempt ${attempt + 1} in ${delayMs / 1000}s or on reconnect`)
1584
+ await new Promise((resolve) => {
1585
+ const wake = () => { clearTimeout(timer); recoveryWaiters.delete(wake); control.wakeRecovery = null; resolve() }
1586
+ // A known event-channel outage can last longer than the retry budget.
1587
+ // Keep accepted work pending until reconnect instead of burning model
1588
+ // attempts while offline. Provider-only failures still use backoff.
1589
+ const timer = setTimeout(() => { if (eventConnection !== 'disconnected') wake() }, delayMs)
1590
+ recoveryWaiters.add(wake)
1591
+ control.wakeRecovery = wake
1592
+ })
1593
+ }
1594
+ }
1595
+
1500
1596
  async function executeCycle(kind, context, targetChannels = [], taskRef = null, delivery = null, runner, control, preparedWorkdir = '') {
1501
1597
  refreshModelSettings()
1502
1598
  const laneName = kind === 'full' ? 'work' : 'reply'
1503
1599
  const cycleControl = control || { cancelled: false, runner, statusTargets: new Set() }
1504
1600
  const cycleStartedAt = performance.now()
1505
- cycleControl.startedAt = cycleStartedAt
1506
- cycleControl.outcome = 'skipped'
1601
+ cycleControl.startedAt ??= cycleStartedAt
1602
+ cycleControl.outcome = 'preflight'
1507
1603
  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
1604
  log(`cycle timing queue=${Math.round(cycleStartedAt - (cycleControl.enqueuedAt ?? cycleStartedAt))}ms kind=${kind}`)
1509
1605
  const ctx = context ? [context] : []
@@ -1511,12 +1607,12 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1511
1607
  let verifiedTaskRevision = ''
1512
1608
  const ticketWorktree = preparedWorkdir || (kind === 'full' && activeTaskRef ? findTicketWorktree(workdir, activeTaskRef.ticketId) : '')
1513
1609
  const runnerOptions = {
1514
- cycleId: cycleControl.cycleId, attempt: 1,
1610
+ cycleId: cycleControl.cycleId, attempt: cycleControl.attempt || 1,
1515
1611
  ...(delivery?.watcherOwned ? { disabledMcpTools: ['post_message'] } : {}),
1516
1612
  ...(ticketWorktree ? { workdir: ticketWorktree } : {}),
1517
1613
  }
1518
1614
  const targets = [...new Set(targetChannels.filter((id) => id != null && Number.isFinite(Number(id))).map(Number))]
1519
- cycleControl.statusTargets = laneName === 'reply' ? replyStatusTargets : new Set(targets)
1615
+ cycleControl.statusTargets = new Set(targets)
1520
1616
  if (laneName === 'reply') {
1521
1617
  replyStatusTargets.clear()
1522
1618
  for (const target of targets) replyStatusTargets.add(target)
@@ -1530,7 +1626,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1530
1626
  try { recalled = await memory.context(memoryRefs) }
1531
1627
  catch (error) { log('Optional history unavailable; agent can retrieve context with its tools: ' + (error?.message || error)) }
1532
1628
  const capabilityContext = kind !== 'full' && canCode
1533
- ? 'YOUR CODING WORKSPACE IS AVAILABLE. If this request needs local execution or edits, call openvisio_request_work_session with the findings and context to carry forward, then end this turn. The same agent continues the same request in its coding workspace. Do not claim to be a chat-only agent or ask anyone to reassign the ticket.'
1629
+ ? 'YOUR CODING WORKSPACE IS AVAILABLE. If this request needs local execution or edits, call openvisio_request_work_session with private continuation context, a short teammate-facing acknowledgement, and 2-3 concrete plan steps. The watcher posts the acknowledgement and plan in this source conversation before starting the work session. Plain final text and internal context are not posted during this handoff: put your public acknowledgement and plan in the tool fields. Then end this turn. The same agent continues the same request in its coding workspace. Do not claim to be a chat-only agent or ask anyone to reassign the ticket.'
1534
1630
  : canCode ? 'This session has your configured coding workspace. Choose the tools and context appropriate to the request.' : 'This connection has no configured local coding workspace. Use your available tools for the request; do not claim local changes you cannot perform.'
1535
1631
  const prompt = identityContext + '\n\n' + capabilityContext + '\n\n' + (ctx.length ? ctx.join('\n') + '\n\n' : '') + (recalled ? recalled + '\n\n' : '') + baseFor(kind, delivery)
1536
1632
  // Chat-shaped cycles (mentions/intro) may run on the cheaper chat model; code
@@ -1539,8 +1635,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1539
1635
  const cycleModelRevision = modelSettingsRevision
1540
1636
  const useModel = kind === 'full' ? codeModel : liteModel
1541
1637
  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.
1638
+ activity.set(cycleControl.cycleId, cycleControl.statusTargets, 'thinking')
1544
1639
  try {
1545
1640
  // The ticket may have been reassigned or handed to review while waiting.
1546
1641
  // Re-check at dequeue, before any model can edit the repository.
@@ -1548,19 +1643,23 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1548
1643
  const data = toolData(await callMcpTool('get_ticket', { project_id: activeTaskRef.projectId, ticket_id: activeTaskRef.ticketId }))
1549
1644
  const ticket = data.ticket ?? data.task ?? data
1550
1645
  verifiedTaskRevision = taskRevision(ticket)
1646
+ cycleControl.taskLabel = ticketDisplaySlug(ticket) || String(ticket.title || 'the task').slice(0, 160)
1551
1647
  const typeSets = await taskTypeSets(activeTaskRef.projectId)
1552
1648
  if (blockedTasks.has(`${activeTaskRef.projectId}:${activeTaskRef.ticketId}`) ||
1553
1649
  !taskBelongsToAgent(ticket, { id: selfAgentId, identifier }) ||
1554
1650
  taskIsCompleted(ticket, typeSets.done) || taskIsAwaitingReview(ticket, typeSets.review)) {
1555
1651
  releaseTaskForRetry(activeTaskRef, prompt)
1652
+ cycleControl.outcome = 'skipped'
1556
1653
  log('queued ticket no longer actionable; skipped before model start')
1557
1654
  return
1558
1655
  }
1559
1656
  }
1560
1657
  if (cycleControl.cancelled) return
1658
+ cycleControl.planKey ||= 'plan:' + (delivery?.key || `${activeTaskRef?.projectId}:${activeTaskRef?.ticketId}:${verifiedTaskRevision}`)
1561
1659
  cycleControl.outcome = 'running'
1562
1660
  log(`cycle timing preflight=${Math.round(performance.now() - cycleStartedAt)}ms kind=${kind}`)
1563
1661
  const result = await runner.runCycle(prompt, useModel, runnerOptions)
1662
+ await cycleControl.planDelivery
1564
1663
  cycleControl.outcome = result?.is_error ? 'error' : result?.subtype || 'error'
1565
1664
  if (result?.timings) log(`cycle timing runtime=${JSON.stringify(result.timings)} kind=${kind}`)
1566
1665
  refreshModelSettings()
@@ -1584,6 +1683,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1584
1683
  return
1585
1684
  }
1586
1685
  if (!cycleSucceeded(result)) {
1686
+ if (kind === 'full' && interruptedRuntimeResult(result) && cycleControl.attempt <= recoveryDelays.length) return { ...result, transportInterrupted: true }
1587
1687
  const outcome = result?.subtype || 'an unknown runtime error'
1588
1688
  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
1689
  log('WORK_CYCLE_BLOCKED ' + outcome + '; publishing blocker')
@@ -1597,6 +1697,11 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1597
1697
  // An agent-selected continuation, not a heuristic retry or a new task.
1598
1698
  // Preserve the authoritative recipient and cancellation key; the tool
1599
1699
  // cannot select another task, channel, identity, or permission scope.
1700
+ const entries = Array.isArray(result.workRequest.plan)
1701
+ ? result.workRequest.plan.map(content => ({ content })) : cycleControl.planEntries
1702
+ try { await publishWorkPlan({ control: cycleControl, delivery, taskRef: activeTaskRef }, entries, result.workRequest.acknowledgement) }
1703
+ catch (error) { log('Task plan delivery unavailable: ' + (error?.message || error)) }
1704
+ if (cycleControl.cancelled || stopping) return
1600
1705
  cycleControl.outcome = 'continued'
1601
1706
  cycleControl.finishedBy = 'agent'
1602
1707
  const continuation = String(result.workRequest.context || '').slice(0, 12000)
@@ -1640,6 +1745,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1640
1745
  }
1641
1746
  }
1642
1747
  } finally {
1748
+ activity.clear(cycleControl.cycleId)
1643
1749
  cycleControl.statusTargets.clear()
1644
1750
  }
1645
1751
  }
@@ -1702,6 +1808,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1702
1808
  for (const [name, queue] of Object.entries(queues)) {
1703
1809
  queue.cancel((item) => String(item.taskRef?.projectId) === String(projectId) && String(item.taskRef?.ticketId) === String(ticketId), (item) => {
1704
1810
  item.control.cancelled = true
1811
+ item.control.wakeRecovery?.()
1705
1812
  void item.control.runner?.cancelCurrent?.()
1706
1813
  })
1707
1814
  }
@@ -1870,10 +1977,10 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1870
1977
  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
1978
  catch { /* optional conversation indexing */ }
1872
1979
  // 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.
1980
+ // redirect can cancel its process. The watcher delivers the final answer
1981
+ // for every runtime after an authoritative last-moment thread check.
1875
1982
  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' }
1983
+ ? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey, sourceMessageId: mid, watcherOwned: true }
1877
1984
  : null
1878
1985
  // Under-the-hood model control from chat (view / switch the model the agent runs).
1879
1986
  const mcmd = cid != null ? parseModelCmd(text) : null
@@ -1958,7 +2065,19 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1958
2065
  // recover channel mentions even when the backend omits targeted dispatch.
1959
2066
  // Both event types share recipient, ownership and deduplication guards.
1960
2067
  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 })
2068
+ handle = (dependencies.connect || connectAgentWs)({ wsUrl, apiKey, identifier, onEvent,
2069
+ onDisconnect: () => {
2070
+ eventConnection = 'disconnected'
2071
+ emit('connection.disconnected', { status: 'disconnected', reason: 'Event connection lost; accepted work remains running' })
2072
+ },
2073
+ onConnect: () => {
2074
+ if (stopping) return
2075
+ eventConnection = 'connected'
2076
+ activity.pulse()
2077
+ emit('connection.connected', { status: 'connected' })
2078
+ for (const wake of [...recoveryWaiters]) wake()
2079
+ if (autoStart) setTimeout(() => { if (!stopping) void reconcileBacklog() }, 250)
2080
+ }, log })
1962
2081
 
1963
2082
  let authorizationProbeBusy = false
1964
2083
  const legacyAuthorizationChecks = new Set()
@@ -1999,7 +2118,14 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1999
2118
  const modelSettingsTimer = autoStart ? setInterval(refreshModelSettings, 2_000) : null
2000
2119
  modelSettingsTimer?.unref?.()
2001
2120
  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
2121
+ const heartbeatTimer = autoStart ? setInterval(() => {
2122
+ 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 || '', ...(agentAppearance ? { profile: agentAppearance } : {}) })
2123
+ // The Studio reads a bounded journal tail. Re-state current queue ownership
2124
+ // so a long-running cycle stays visible after its start event rotates out.
2125
+ for (const { data, control } of liveCycles.values()) {
2126
+ if (!control.cancelled) emit('cycle.snapshot', { ...data, status: control.outcome, attempt: control.attempt || 1 })
2127
+ }
2128
+ }, 15_000) : null
2003
2129
  heartbeatTimer?.unref?.()
2004
2130
  let introTimer = null, taskProbeStartTimer = null, taskProbeTimer = null
2005
2131
  if (autoStart && (mcpConfig || mcpUrl)) {
@@ -2039,12 +2165,13 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
2039
2165
  if (taskProbeTimer) clearInterval(taskProbeTimer)
2040
2166
  try { handle?.close() } catch { /* already disconnected */ }
2041
2167
  for (const queue of Object.values(queues)) {
2042
- queue.cancel(() => true, (item) => { item.control.cancelled = true })
2168
+ queue.cancel(() => true, (item) => { item.control.cancelled = true; item.control.wakeRecovery?.() })
2043
2169
  }
2044
2170
  await Promise.allSettled([...activeReplyRunners, ...activeWorkRunners].map((runner) => Promise.resolve().then(() => runner.cancelCurrent?.())))
2045
2171
  await Promise.allSettled([...mentionIntake.values(), ...Object.values(queues).map((queue) => queue.settled()), ...messageDeliveries.values(), ...pendingOperations])
2046
2172
  workRunners.close()
2047
2173
  replyRunners.close()
2174
+ await activity.close()
2048
2175
  try { await contextService.close() } catch (error) { log('context search shutdown failed: ' + (error?.message || error)) }
2049
2176
  try { await memory.settled?.() }
2050
2177
  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
@@ -1,4 +1,5 @@
1
1
  // The studio displays recorded, visible actions. It never starts work or infers a plan.
2
+ import { botAvatarUrl } from './bot-avatar.mjs';
2
3
  const MAX_EVENTS = 1000;
3
4
  const PAGE_SIZE = 40;
4
5
  const terminalStatuses = new Set(['completed', 'failed', 'cancelled', 'offline', 'skipped', 'blocked', 'timeout', 'continued']);
@@ -82,7 +83,7 @@ export function deriveModel(input) {
82
83
  if (event.type.startsWith('cycle.')) {
83
84
  if (data.kind != null) cycle.data.kind = data.kind;
84
85
  cycle.status = eventStatus(event);
85
- if (event.type === 'cycle.started') cycle.startedAt = event.timestamp;
86
+ if (event.type === 'cycle.started') cycle.startedAt ||= event.timestamp;
86
87
  if (terminalStatuses.has(cycle.status)) cycle.finishedAt = event.timestamp;
87
88
  }
88
89
  if (event.type.startsWith('tool.')) {
@@ -287,6 +288,14 @@ function initializeStudio() {
287
288
  const element = node('span', `badge ${status}`, label);
288
289
  return element;
289
290
  }
291
+ function agentImage(agent, size = 31) {
292
+ const profile = state.model.agents.find(item => agentId(item) === agentId(agent)) || agent;
293
+ const image = node('img', 'bot-avatar');
294
+ image.src = botAvatarUrl(profile.avatarSeed || profile.slug || profile.identifier || profile.id);
295
+ image.alt = ''; image.setAttribute('aria-hidden', 'true');
296
+ image.width = size; image.height = size;
297
+ return image;
298
+ }
290
299
  function toast(message) {
291
300
  clearTimeout(toastTimer);
292
301
  $('toast').textContent = message;
@@ -352,10 +361,12 @@ function initializeStudio() {
352
361
  button.setAttribute('aria-pressed', String(state.selectedAgent === id));
353
362
  const avatar = node('span', 'agent-avatar');
354
363
  if (id === 'all') avatar.append(icon('agents'));
355
- else { avatar.textContent = name.slice(0, 2).toUpperCase(); avatar.append(node('span', `avatar-status ${agent.status}`)); }
364
+ else { avatar.classList.add('has-image'); avatar.append(agentImage(agent), node('span', `avatar-status ${agent.status}`)); }
356
365
  const body = node('span', 'agent-text');
357
366
  body.append(node('span', 'agent-name', name), node('span', 'agent-caption', caption));
358
- button.append(avatar, body);
367
+ const selection = node('span', 'agent-selection'); selection.append(icon('check'));
368
+ selection.setAttribute('aria-hidden', 'true');
369
+ button.append(avatar, body, selection);
359
370
  button.addEventListener('click', () => { state.selectedAgent = id; resetSelection(); scheduleRender(); });
360
371
  return button;
361
372
  }
@@ -704,9 +715,11 @@ function initializeStudio() {
704
715
  branch.setAttribute('aria-pressed', String(item.id === cycle.id));
705
716
  const marker = node('span', `fanout-marker ${item.status}`);
706
717
  marker.append(icon(item.status === 'completed' ? 'check' : ['failed', 'cancelled', 'blocked', 'timeout'].includes(item.status) ? 'close' : ['active', 'recovering'].includes(item.status) ? 'loader' : 'clock'));
707
- branch.append(marker, node('span', 'fanout-name', agentName(item.agent)));
718
+ const identity = node('span', 'fanout-identity');
719
+ identity.append(agentImage(item.agent, 20), node('span', 'fanout-name', agentName(item.agent)));
720
+ branch.append(marker, identity);
708
721
  branch.addEventListener('click', () => {
709
- state.selectedCycle = item.id; state.selectedEvent = null; state.selectedAgent = 'all';
722
+ state.selectedCycle = item.id; state.selectedEvent = null; state.selectedAgent = agentId(item.agent);
710
723
  state.view = 'cycles'; state.search = ''; state.kind = 'all'; state.status = 'all';
711
724
  $('event-search').value = ''; $('kind-filter').value = 'all'; $('status-filter').value = 'all';
712
725
  state.follow = false; $('follow-latest').checked = false; scheduleRender();
@@ -0,0 +1,70 @@
1
+ // Embedded version of frontend/components/team/BotAvatar.tsx. Keep the seed,
2
+ // palettes and SVG geometry identical so an agent looks the same in both apps.
3
+ // Only generated numbers and fixed shapes enter the SVG; no remote images.
4
+ function hashSeed(seed) {
5
+ let h = 2166136261 >>> 0;
6
+ for (let i = 0; i < seed.length; i++) { h ^= seed.charCodeAt(i); h = Math.imul(h, 16777619); }
7
+ return h >>> 0;
8
+ }
9
+ function mulberry32(seed) {
10
+ let a = seed >>> 0;
11
+ return () => {
12
+ a = (a + 0x6d2b79f5) | 0;
13
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
14
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
15
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
16
+ };
17
+ }
18
+ const catalogHues = { sentinel: 0, forge: 40, pulse: 80, nova: 120, sage: 160, atlas: 200, vera: 240, relay: 280, quill: 320 };
19
+ function botSpec(seed) {
20
+ const h = Object.hasOwn(catalogHues, seed) ? catalogHues[seed] : hashSeed(seed + '#hue') % 360;
21
+ const v = hashSeed(seed + '#tone') % 97, saturation = 58 + v % 20, lightness = 50 + v % 9;
22
+ const rng = mulberry32(hashSeed(seed));
23
+ const pick = choices => choices[Math.floor(rng() * choices.length)];
24
+ return {
25
+ p: {
26
+ tile: `hsl(${h} ${Math.min(saturation + 6, 84)}% 90%)`,
27
+ body: `hsl(${h} ${saturation}% ${lightness}%)`,
28
+ dark: `hsl(${h} ${Math.min(saturation + 4, 82)}% ${lightness - 17}%)`,
29
+ face: `hsl(${h} 48% 97%)`,
30
+ accent: `hsl(${(h + 150 + v % 61) % 360} 82% 56%)`,
31
+ },
32
+ headRx: pick([14, 20, 26, 26]),
33
+ antenna: pick(['none', 'single', 'double', 'dish']),
34
+ eyes: pick(['round', 'square', 'visor', 'cyclops', 'happy']),
35
+ mouth: pick(['grid', 'smile', 'flat', 'dots', 'wave']),
36
+ };
37
+ }
38
+
39
+ export function botAvatarSvg(seed) {
40
+ const spec = botSpec(String(seed || 'bot')), { p } = spec;
41
+ const element = (tag, attributes, children = '') => `<${tag} ${Object.entries(attributes).map(([key, value]) => `${key}="${value}"`).join(' ')}>${children}</${tag}>`;
42
+ const circle = (cx, cy, r, fill, more = {}) => element('circle', { cx, cy, r, fill, ...more });
43
+ const rect = (x, y, width, height, rx, fill) => element('rect', { x, y, width, height, rx, fill });
44
+ const line = (x1, y1, x2, y2, more = {}) => element('line', { x1, y1, x2, y2, ...more });
45
+ const stroke = (color, width) => ({ stroke: color, 'stroke-width': width, 'stroke-linecap': 'round' });
46
+ const antenna = {
47
+ single: element('g', stroke(p.dark, 3), line(50, 26, 50, 15) + circle(50, 12, 4, p.accent, { stroke: 'none' })),
48
+ double: element('g', stroke(p.dark, 2.6), line(40, 26, 37, 16) + line(60, 26, 63, 16) + circle(36, 14, 3.2, p.accent, { stroke: 'none' }) + circle(64, 14, 3.2, p.accent, { stroke: 'none' })),
49
+ dish: element('g', {}, line(50, 26, 50, 16, stroke(p.dark, 3)) + element('ellipse', { cx: 50, cy: 14, rx: 10, ry: 3.4, fill: p.dark }) + circle(50, 12, 2.4, p.accent)),
50
+ }[spec.antenna] || '';
51
+ const eyes = {
52
+ round: circle(42, 48, 5, p.dark) + circle(58, 48, 5, p.dark) + circle(43.4, 46.6, 1.6, '#fff') + circle(59.4, 46.6, 1.6, '#fff'),
53
+ square: rect(37, 43, 9, 9, 2.5, p.dark) + rect(54, 43, 9, 9, 2.5, p.dark),
54
+ visor: element('g', {}, rect(36, 44, 28, 9, 4.5, p.dark) + circle(44, 48.5, 2.4, p.accent) + circle(56, 48.5, 2.4, p.accent)),
55
+ cyclops: circle(50, 48, 8, p.dark) + circle(50, 48, 3.4, p.accent),
56
+ happy: element('g', { ...stroke(p.dark, 3), fill: 'none' }, element('path', { d: 'M37 49 q5 -6 10 0' }) + element('path', { d: 'M53 49 q5 -6 10 0' })),
57
+ }[spec.eyes];
58
+ const mouth = {
59
+ grid: element('g', {}, rect(42, 58, 16, 6.5, 2, p.dark) + [47, 50, 53].map(x => line(x, 58, x, 64.5, { stroke: p.face, 'stroke-width': 1.2 })).join('')),
60
+ smile: element('path', { d: 'M42 59 q8 7 16 0', ...stroke(p.dark, 3), fill: 'none' }),
61
+ flat: rect(43, 60.5, 14, 3, 1.5, p.dark),
62
+ dots: element('g', { fill: p.dark }, [44, 50, 56].map(cx => element('circle', { cx, cy: 61, r: 1.8 })).join('')),
63
+ wave: element('path', { d: 'M42 61 q3 -4 6 0 t6 0', ...stroke(p.dark, 2.6), fill: 'none' }),
64
+ }[spec.mouth];
65
+ return element('svg', { xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 100 100' }, rect(0, 0, 100, 100, 24, p.tile) + antenna + rect(18, 46, 5, 16, 2.5, p.dark) + rect(77, 46, 5, 16, 2.5, p.dark) + rect(24, 26, 52, 50, spec.headRx, p.body) + rect(31, 35, 38, 32, 9, p.face) + eyes + mouth);
66
+ }
67
+
68
+ export function botAvatarUrl(seed) {
69
+ return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(botAvatarSvg(seed));
70
+ }
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,12 +78,18 @@
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); }
83
- .agent-item.is-selected { background: var(--stage); }
85
+ .agent-item.is-selected { background: color-mix(in oklab, var(--ink) 8%, transparent); box-shadow: inset 0 0 0 1px var(--border); }
86
+ .agent-selection { display: grid; place-items: center; width: 15px; flex-shrink: 0; visibility: hidden; }
87
+ .agent-selection .icon { width: 14px; height: 14px; }
88
+ .agent-item.is-selected .agent-selection { visibility: visible; }
84
89
  .agent-avatar { width: 31px; height: 31px; display: grid; place-items: center; position: relative; flex-shrink: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; corner-shape: squircle; color: var(--muted); font-size: 12px; font-weight: 500; }
90
+ .agent-avatar.has-image { border: 0; background: transparent; }
91
+ .bot-avatar { display: block; flex-shrink: 0; }
92
+ .fanout-identity { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 0; max-width: 100%; }
85
93
  .avatar-status { position: absolute; width: 7px; height: 7px; right: -1px; bottom: -1px; border-radius: 50%; border: 2px solid var(--background); box-sizing: content-box; background: var(--subtle); }
86
94
  .avatar-status.online { background: var(--success); }
87
95
  .agent-text { flex: 1; min-width: 0; }
@@ -123,7 +131,7 @@
123
131
  .filters { display: flex; gap: 9px; margin-bottom: 19px; }
124
132
  .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
133
  .search-field .icon { width: 14px; height: 14px; }
126
- .search-field input { width: 100%; background: transparent; border: 0; font-size: 12px; outline-offset: 4px; }
134
+ .search-field input { width: 100%; background: transparent; border: 0; font-size: 12px; }
127
135
  .select-field { display: inline-flex; align-items: center; min-width: 108px; }
128
136
  .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
137
  .content-grid { display: grid; grid-template-columns: minmax(280px, .85fr) minmax(360px, 1.15fr); gap: 14px; align-items: start; }
@@ -360,7 +368,6 @@
360
368
  .model-settings-form label { display: grid; gap: 4px; font-size: 13px; margin-top: 8px; }
361
369
  .model-settings-form label span { font-size: 12px; color: var(--muted); }
362
370
  .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
371
  .model-settings-actions { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px; margin-top: 8px; }
365
372
  #model-settings-error { color: #9f3030; }
366
373
  @media (max-width: 640px) {