openvisio-agent 0.25.9 → 0.25.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,11 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## [0.25.10] — 2026-09-21
4
+
5
+ - Send `thinking`, `working`, and `typing` activity through the authenticated agent WebSocket, with the REST endpoint retained only while the socket is reconnecting.
6
+ - Accept documented and compatible WebSocket event envelopes on both the watcher and frontend, including flat bare-agent `agent_status` frames, and normalize them before routing.
7
+ - Keep active cycles running through ordinary peer-agent chatter; only an analyzed redirect or explicit stand-down cancels work in that thread.
8
+ - Compact Agent Studio activity and cycle rows into single-line summaries so more history remains visible at once.
4
9
 
5
10
  ## [0.25.9] — 2026-09-21
6
11
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.25.9",
3
+ "version": "0.25.10",
4
4
  "description": "Connect Claude Code, Codex, OpenCode, Gemini CLI, or Qwen Code to an OpenVisio team \u2014 MCP tools + optional autonomy \u2014 in one command.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,6 +18,7 @@ for (const directory of ['src', 'bin', 'studio']) {
18
18
  }
19
19
  run('unit and behavior tests', process.execPath, ['--test'])
20
20
  run('frontend typecheck', 'npm', ['run', 'typecheck'], join(repo, 'frontend'))
21
+ run('frontend realtime frame tests', process.execPath, ['--test', 'lib/api/backend/realtimeFrame.test.mjs'], join(repo, 'frontend'))
21
22
  run('package dry run', 'npm', ['pack', '--dry-run'])
22
23
  run('diff whitespace check', 'git', ['diff', '--check'], repo)
23
24
 
@@ -32,6 +33,7 @@ const codexProxy = readFileSync(join(root, 'src', 'codex-mcp-proxy.mjs'), 'utf8'
32
33
  const prPush = readFileSync(join(root, 'src', 'pr-push.mjs'), 'utf8')
33
34
  const cli = readFileSync(join(root, 'bin', 'cli.mjs'), 'utf8')
34
35
  const websocket = readFileSync(join(root, 'src', 'ws.mjs'), 'utf8')
36
+ const activityReporter = readFileSync(join(root, 'src', 'activity-reporter.mjs'), 'utf8')
35
37
  const cycleQueue = readFileSync(join(root, 'src', 'cycle-queue.mjs'), 'utf8')
36
38
  const mastraHarness = readFileSync(join(root, 'src', 'mastra-harness.mjs'), 'utf8')
37
39
  const runtimeProviders = readFileSync(join(root, 'src', 'runtime-providers.mjs'), 'utf8')
@@ -100,7 +102,7 @@ const assertions = [
100
102
  ['work and reply cancellation targets are isolated', watcher.includes('item.control.cancelled = true') && watcher.includes('item.control.runner?.cancelCurrent')],
101
103
  ['fresh threads resolve the dedicated agent channel', watcher.includes('dedicatedChannel(Array.isArray(channels)') && watcher.includes('No dedicated agent channel available for a new thread')],
102
104
  ['presence follows runtime activity and stops refreshing with its cycle', watcher.includes('createActivityReporter') && watcher.includes("activity.set(data.cycleId, live.control.statusTargets, 'working')") && watcher.includes('activity.clear(cycleControl.cycleId)')],
103
- ['websocket client cannot emit legacy agent_status', !websocket.includes('agent_status')],
105
+ ['agent activity prefers websocket with REST reconnect fallback', activityReporter.includes("type: 'agent_status'") && activityReporter.includes('sendFrame?.') && activityReporter.includes('agentStateRequest') && websocket.includes('return true')],
104
106
  ['frontend consumes thinking event', activityHook.includes("'channel:agent:thinking'")],
105
107
  ['frontend consumes working event', activityHook.includes("'channel:agent:working'")],
106
108
  ['frontend consumes typing event', activityHook.includes("'channel:agent:typing'")],
@@ -2,11 +2,11 @@ import { agentStateRequest } from './events.mjs'
2
2
 
3
3
  // Presence is ephemeral, independent of task completion and message delivery.
4
4
  // The backend supports no "done" event; stopping refresh lets the UI TTL clear it.
5
- export function createActivityReporter({ backend, apiKey, identifier, fetchImpl = fetch, now = Date.now, log = () => {}, intervalMs = 3_000 }) {
5
+ export function createActivityReporter({ backend, apiKey, identifier, sendFrame, fetchImpl = fetch, now = Date.now, log = () => {}, intervalMs = 3_000 }) {
6
6
  const cycles = new Map(), channels = new Map(), pending = new Set()
7
7
  let closed = false
8
8
  const pulse = () => {
9
- if (closed || !backend) return
9
+ if (closed || (!backend && !sendFrame)) return
10
10
  const states = new Map()
11
11
  const priority = { thinking: 0, working: 1, typing: 2 }
12
12
  for (const cycle of cycles.values()) for (const channelId of cycle.channels) {
@@ -15,11 +15,19 @@ export function createActivityReporter({ backend, apiKey, identifier, fetchImpl
15
15
  for (const [channelId, state] of states) {
16
16
  const prior = channels.get(channelId)
17
17
  if (prior?.busy || now() < (prior?.retryAt || 0) || (prior?.state === state && now() - prior.at < (state === 'working' ? 10_000 : 3_000))) continue
18
+ const entry = { state, at: now(), busy: false, controller: null }
19
+ channels.set(channelId, entry)
20
+ // The authenticated agent socket is the live event path. Older backend
21
+ // stages and reconnect gaps retain the REST endpoint as a fallback.
22
+ try {
23
+ if (sendFrame?.({ type: 'agent_status', channel_id: channelId, state }) === true) continue
24
+ } catch { /* fall through to REST */ }
25
+ if (!backend) continue
18
26
  const controller = new AbortController()
19
27
  const timer = setTimeout(() => controller.abort(), 5_000)
20
28
  timer.unref?.()
21
- const entry = { state, at: now(), busy: true, controller }
22
- channels.set(channelId, entry)
29
+ entry.busy = true
30
+ entry.controller = controller
23
31
  const request = agentStateRequest(backend, channelId, state, apiKey, identifier)
24
32
  const operation = Promise.resolve().then(() => fetchImpl(request.url, { ...request.init, signal: controller.signal })).then(async response => {
25
33
  await response.body?.cancel?.()
@@ -42,6 +50,6 @@ export function createActivityReporter({ backend, apiKey, identifier, fetchImpl
42
50
  clear(cycleId) { cycles.delete(cycleId); pulse() },
43
51
  pulse,
44
52
  async settled() { await Promise.allSettled([...pending]) },
45
- async close() { closed = true; clearInterval(timer); cycles.clear(); for (const entry of channels.values()) entry.controller.abort(); await Promise.allSettled([...pending]) },
53
+ async close() { closed = true; clearInterval(timer); cycles.clear(); for (const entry of channels.values()) entry.controller?.abort(); await Promise.allSettled([...pending]) },
46
54
  }
47
55
  }
package/src/watch.mjs CHANGED
@@ -619,7 +619,10 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
619
619
  // Activity belongs to an individual cycle. Independent ticket workers must
620
620
  // not clear or overwrite each other's status targets.
621
621
  const replyStatusTargets = new Set()
622
- const activity = dependencies.activityReporter || createActivityReporter({ backend, apiKey, identifier, log })
622
+ const activity = dependencies.activityReporter || createActivityReporter({
623
+ backend, apiKey, identifier, log,
624
+ sendFrame: (frame) => handle?.send?.(frame) === true,
625
+ })
623
626
  const sendStatus = (channelId, state) => {
624
627
  const id = cycleContext.getStore()?.cycleId
625
628
  const live = liveCycles.get(id)
@@ -2074,10 +2077,10 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
2074
2077
  const target = classifyConversationTarget(msg, [...selfAliases], { threadOwned })
2075
2078
  if (target.action === 'ignore') {
2076
2079
  log('agent:mention ignored before model start (' + target.reason + ')')
2077
- // A redirect or another agent taking over invalidates any older work or
2078
- // draft still queued for this thread. The event name alone cannot retain
2079
- // ownership after the actual message says otherwise.
2080
- if (cid != null && threadRoot != null && (target.reason === 'explicit-other-recipient' || target.reason === 'other-agent-chatter' || target.reason === 'redirected-to-later-agent')) {
2080
+ // Ordinary peer chatter is additional thread context, not cancellation.
2081
+ // Only a message whose recipient analysis shows an actual handoff may
2082
+ // invalidate older work; explicit stand-down is handled below.
2083
+ if (cid != null && threadRoot != null && (target.reason === 'explicit-other-recipient' || target.reason === 'redirected-to-later-agent')) {
2081
2084
  cancelThread(cid, threadRoot, text || target.reason)
2082
2085
  }
2083
2086
  return
package/src/ws.mjs CHANGED
@@ -18,6 +18,31 @@ const KEEPALIVE_MS = 30_000 // refresh last_seen_at well within any idle timeout
18
18
  const BACKOFF_MIN_MS = 1_000
19
19
  const BACKOFF_MAX_MS = 30_000
20
20
 
21
+ const own = (value, key) => Object.prototype.hasOwnProperty.call(value, key)
22
+
23
+ /** Accept the documented `{k,d}` event and compatible backend relay envelopes.
24
+ * Keeping this at the transport boundary prevents provider/watch logic from
25
+ * learning several wire formats. */
26
+ export function normalizeAgentWsEvent(raw) {
27
+ let msg = raw
28
+ if (typeof msg === 'string') { try { msg = JSON.parse(msg) } catch { return null } }
29
+ if (!msg || typeof msg !== 'object' || Array.isArray(msg)) return null
30
+ if (!msg.k && !msg.kind && !msg.event && !msg.event_type && !msg.eventType && !msg.type && own(msg, 'body')) {
31
+ return normalizeAgentWsEvent(msg.body)
32
+ }
33
+ const kind = [msg.k, msg.kind, msg.event, msg.event_type, msg.eventType, msg.type].find(value => typeof value === 'string' && value.trim())
34
+ if (!kind || kind === 'event') return null
35
+ let payload
36
+ if (own(msg, 'd')) payload = msg.d
37
+ else if (own(msg, 'payload')) payload = msg.payload
38
+ else if (own(msg, 'data')) payload = msg.data
39
+ else {
40
+ const { k: _k, kind: _kind, event: _event, event_type: _eventType, eventType: _eventTypeCamel, type: _type, ...body } = msg
41
+ payload = body
42
+ }
43
+ return { kind, payload, envelope: msg }
44
+ }
45
+
21
46
  /** Bail early with a friendly message if the runtime has no global WebSocket. */
22
47
  export function assertWebSocket(fail) {
23
48
  if (typeof globalThis.WebSocket === 'function') return
@@ -86,13 +111,12 @@ export function connectAgentWs({ wsUrl, apiKey, identifier, onEvent, onConnect,
86
111
 
87
112
  sock.addEventListener('message', (ev) => {
88
113
  if (sock !== ws) return
89
- let msg
90
- try { msg = JSON.parse(typeof ev.data === 'string' ? ev.data : String(ev.data)) } catch { return }
91
- const kind = msg && msg.k
114
+ const event = normalizeAgentWsEvent(typeof ev.data === 'string' ? ev.data : String(ev.data))
115
+ const kind = event?.kind
92
116
  if (!kind || kind === 'keepalive:ack') return // keepalive round-trip, nothing to do
93
117
  // Error metadata often lives on the JSON envelope rather than `d`. Preserve
94
118
  // it so callers do not receive an unhelpful empty object.
95
- try { onEvent(kind, kind === 'error' ? msg : msg.d) } catch (e) { log('event handler error: ' + (e && e.message ? e.message : e)) }
119
+ try { onEvent(kind, kind === 'error' ? event.envelope : event.payload) } catch (e) { log('event handler error: ' + (e && e.message ? e.message : e)) }
96
120
  })
97
121
 
98
122
  const down = (why) => {
@@ -119,8 +143,14 @@ export function connectAgentWs({ wsUrl, apiKey, identifier, onEvent, onConnect,
119
143
  subscribe()
120
144
  return true
121
145
  },
122
- /** Fire-and-forget send (no-op if the socket isn't open). */
123
- send(obj) { try { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)) } catch { /* closing */ } },
146
+ /** Fire-and-forget send. Returns whether the frame reached an open socket. */
147
+ send(obj) {
148
+ try {
149
+ if (!ws || ws.readyState !== 1) return false
150
+ ws.send(JSON.stringify(obj))
151
+ return true
152
+ } catch { return false }
153
+ },
124
154
  close() {
125
155
  closed = true
126
156
  clearKeepalive()
package/studio/style.css CHANGED
@@ -139,18 +139,19 @@
139
139
  .section-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 4px 2px 15px; min-height: 37px; }
140
140
  .section-heading h2 { font-size: 12px; }
141
141
  .section-heading .muted { font-size: 12px; }
142
- .activity-list { display: flex; flex-direction: column; gap: 8px; max-height: min(67vh, 810px); min-height: 380px; overflow: auto; overscroll-behavior: contain; scrollbar-width: thin; padding: 2px; }
143
- .activity-row { display: flex; align-items: flex-start; gap: 11px; text-align: left; width: 100%; padding: 16px 14px; border: 1px solid transparent; border-radius: var(--radius-md); corner-shape: squircle; background: transparent; }
142
+ .activity-list { display: flex; flex-direction: column; gap: 2px; max-height: min(67vh, 810px); min-height: 380px; overflow: auto; overscroll-behavior: contain; scrollbar-width: thin; padding: 2px; }
143
+ .activity-row { display: flex; align-items: center; gap: 8px; text-align: left; width: 100%; min-height: 40px; padding: 6px 9px; border: 1px solid transparent; border-radius: 12px; corner-shape: squircle; background: transparent; }
144
144
  .activity-row:hover { background: var(--hover); }
145
145
  .activity-row.is-selected { background: var(--surface); border-color: transparent; box-shadow: 0 1px 2px #00000006; }
146
- .event-icon { width: 34px; height: 34px; display: grid; place-items: center; flex-shrink: 0; color: var(--muted); background: var(--surface); border: 1px solid var(--border); border-radius: 10px; corner-shape: squircle; margin-top: 1px; }
147
- .event-icon .icon { width: 18px; height: 18px; }
148
- .event-body { min-width: 0; flex: 1; }
149
- .event-title { display: block; font-size: 14px; font-weight: 500; line-height: 1.55; overflow-wrap: anywhere; }
150
- .event-description { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; font-size: 12px; font-weight: 400; color: var(--muted); margin-top: 4px; line-height: 1.7; overflow-wrap: anywhere; }
151
- .event-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 5px 8px; font-size: 12px; font-weight: 400; color: var(--muted); margin-top: 7px; }
152
- .event-meta .badge { font-size: 12px; padding: 1px 6px; }
153
- .event-time { font-size: 12px; color: var(--subtle); white-space: nowrap; font-weight: 400; font-family: var(--font-mono); margin-top: 4px; }
146
+ .event-icon { width: 27px; height: 27px; display: grid; place-items: center; flex-shrink: 0; color: var(--muted); background: var(--surface); border: 1px solid var(--border); border-radius: 8px; corner-shape: squircle; }
147
+ .event-icon .icon { width: 14px; height: 14px; }
148
+ .event-body { min-width: 0; flex: 1; display: flex; align-items: center; gap: 7px; overflow: hidden; }
149
+ .event-title { flex: 0 1 auto; max-width: 38%; font-size: 12px; font-weight: 500; line-height: 1.35; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
150
+ .event-description { flex: 1 1 auto; min-width: 24px; overflow: hidden; font-size: 11px; font-weight: 400; color: var(--muted); margin: 0; line-height: 1.35; white-space: nowrap; text-overflow: ellipsis; }
151
+ .event-meta { display: flex; align-items: center; flex: 0 1 auto; min-width: 0; flex-wrap: nowrap; gap: 5px; font-size: 11px; font-weight: 400; color: var(--muted); margin: 0; white-space: nowrap; overflow: hidden; }
152
+ .event-meta > span:not(.badge) { max-width: 86px; overflow: hidden; text-overflow: ellipsis; }
153
+ .event-meta .badge { font-size: 10px; padding: 1px 5px; }
154
+ .event-time { font-size: 10px; color: var(--subtle); white-space: nowrap; font-weight: 400; font-family: var(--font-mono); margin: 0; }
154
155
  .badge { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; border-radius: 999px; background: var(--hover); color: var(--muted); font-size: 12px; font-weight: 400; white-space: nowrap; }
155
156
  .badge .dot { width: 4px; height: 4px; }
156
157
  .badge.active, .badge.completed, .badge.online { color: var(--success); }
@@ -353,10 +354,10 @@
353
354
  .guide-callout { padding: 18px; border-radius: var(--radius-md); background: var(--stage); }
354
355
  @media print { .guide-nav, .guide-header .button, .skip-link { display: none; } .guide-layout { display: block; } .guide-shell { max-width: none; padding: 0; } .guide-content section { break-inside: avoid; } }
355
356
  @media (max-width: 640px) { .guide-shell { padding: 24px 20px; } .guide-layout { grid-template-columns: minmax(0, 1fr); gap: 24px; } .guide-nav { position: static; } .panel-toolbar { flex-wrap: wrap; } }
356
- @media (min-width: 1550px) { .workspace { padding-bottom: 28px; } .content-grid { grid-template-columns: minmax(280px, .85fr) minmax(360px, 1.15fr); gap: 24px; } .activity-row { padding: 15px; } .inspector { padding: 2px; } }
357
+ @media (min-width: 1550px) { .workspace { padding-bottom: 28px; } .content-grid { grid-template-columns: minmax(280px, .85fr) minmax(360px, 1.15fr); gap: 24px; } .activity-row { padding: 6px 9px; } .inspector { padding: 2px; } }
357
358
  @media (max-width: 1120px) { .app-shell { grid-template-columns: 185px minmax(0, 1fr); gap: 20px; width: calc(100% - 40px); } .sidebar { padding-inline: 10px; } .workspace { padding: 8px 0 18px; } .content-grid { grid-template-columns: minmax(250px, 1fr) minmax(270px, 1fr); } .metrics { gap: 8px; } .header-actions { gap: 10px; } .metric-caption { font-size: 12px; } }
358
359
  @media (max-width: 940px) { .content-grid { grid-template-columns: minmax(0, 1fr); } .inspector { max-height: none; min-height: 220px; } .inspector-empty { min-height: 200px; } .activity-list { min-height: 250px; max-height: 440px; } .metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .page-header { align-items: flex-start; } .header-actions { flex-direction: column; align-items: flex-end; padding-top: 7px; gap: 13px; } .page-description { max-width: 230px; } }
359
- @media (max-width: 640px) { .app-shell { grid-template-columns: minmax(0, 1fr); width: 100%; margin: 0 auto; gap: 0; min-height: 100dvh; } .sidebar { position: static; height: auto; padding: 16px 18px 8px; border: 0; border-radius: 0; overflow-y: visible; } .brand { margin: 0 0 16px; font-size: 13px; } .brand-caption { display: none; } .brand-mark { width: 28px; height: 28px; } .brand-mark img { width: 56px; height: 56px; } .sidebar-title, .agent-search, .sidebar-note { display: none; } .agent-list { flex-direction: row; overflow-x: auto; padding: 2px 2px 7px; gap: 6px; } .agent-item { width: auto; min-width: 132px; max-width: 170px; padding: 8px; flex-shrink: 0; } .agent-avatar { width: 25px; height: 25px; font-size: 12px; } .agent-name { font-size: 12px; } .agent-caption { font-size: 12px; } .workspace { padding: 17px 18px 20px; } .page-header { margin-bottom: 22px; gap: 10px; } h1 { font-size: 27px; } .page-description { font-size: 12px; max-width: 210px; } .eyebrow { font-size: 12px; } .header-actions .button { min-height: 40px; font-size: 12px; padding: 6px 10px; } .connection-status { font-size: 12px; } .metrics { margin-bottom: 22px; } .metric-value { font-size: 27px; } .metric-label { font-size: 12px; } .panel-toolbar { gap: 8px; } .tab { padding: 7px 9px; font-size: 12px; gap: 5px; } .tab .icon { width: 12px; height: 12px; } .follow-control { font-size: 12px; gap: 4px; } .filters { flex-wrap: wrap; gap: 7px; } .search-field { flex-basis: 100%; } .select-field { flex: 1; } .select-field select { font-size: 12px; } .content-grid { gap: 20px; } .page-footer { flex-direction: column; gap: 4px; } .event-time { font-size: 12px; } .activity-row { gap: 8px; padding: 12px 8px; } .inspector { padding: 2px; } }
360
+ @media (max-width: 640px) { .app-shell { grid-template-columns: minmax(0, 1fr); width: 100%; margin: 0 auto; gap: 0; min-height: 100dvh; } .sidebar { position: static; height: auto; padding: 16px 18px 8px; border: 0; border-radius: 0; overflow-y: visible; } .brand { margin: 0 0 16px; font-size: 13px; } .brand-caption { display: none; } .brand-mark { width: 28px; height: 28px; } .brand-mark img { width: 56px; height: 56px; } .sidebar-title, .agent-search, .sidebar-note { display: none; } .agent-list { flex-direction: row; overflow-x: auto; padding: 2px 2px 7px; gap: 6px; } .agent-item { width: auto; min-width: 132px; max-width: 170px; padding: 8px; flex-shrink: 0; } .agent-avatar { width: 25px; height: 25px; font-size: 12px; } .agent-name { font-size: 12px; } .agent-caption { font-size: 12px; } .workspace { padding: 17px 18px 20px; } .page-header { margin-bottom: 22px; gap: 10px; } h1 { font-size: 27px; } .page-description { font-size: 12px; max-width: 210px; } .eyebrow { font-size: 12px; } .header-actions .button { min-height: 40px; font-size: 12px; padding: 6px 10px; } .connection-status { font-size: 12px; } .metrics { margin-bottom: 22px; } .metric-value { font-size: 27px; } .metric-label { font-size: 12px; } .panel-toolbar { gap: 8px; } .tab { padding: 7px 9px; font-size: 12px; gap: 5px; } .tab .icon { width: 12px; height: 12px; } .follow-control { font-size: 12px; gap: 4px; } .filters { flex-wrap: wrap; gap: 7px; } .search-field { flex-basis: 100%; } .select-field { flex: 1; } .select-field select { font-size: 12px; } .content-grid { gap: 20px; } .page-footer { flex-direction: column; gap: 4px; } .event-time { font-size: 10px; } .activity-row { gap: 6px; padding: 6px; } .event-description { display: none; } .inspector { padding: 2px; } }
360
361
  .model-settings-summary:not([hidden]) { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 14px; padding: 18px; margin-bottom: 22px; border: 1px solid var(--border); border-radius: 20px; background: var(--surface, white); }
361
362
  .model-settings-summary h2 { margin: 0 0 6px; font-size: 13px; font-weight: 500; }
362
363
  .model-settings-summary p { margin: 0; color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }