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