openvisio-agent 0.23.0 → 0.24.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +4 -2
- package/USER_GUIDE.md +4 -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/runtime-control.mjs +16 -5
- package/src/studio-server.mjs +8 -3
- package/src/transport-recovery.mjs +25 -0
- package/src/watch.mjs +153 -26
- package/src/ws.mjs +3 -2
- package/studio/app.mjs +18 -5
- package/studio/bot-avatar.mjs +70 -0
- package/studio/style.css +11 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.24.1] — 2026-09-10
|
|
4
|
+
|
|
5
|
+
- Deliver agent-authored acknowledgements and short plans to the originating channel or thread when a request moves into a coding workspace.
|
|
6
|
+
- Highlight the selected agent in Studio and show embedded avatars shared with the main app.
|
|
7
|
+
|
|
8
|
+
## [0.24.0] — 2026-09-10
|
|
9
|
+
|
|
10
|
+
- Acknowledge accepted coding tasks with the agent's short native plan, then deliver its final response separately. Deduplicate plans through reconnects and use guarded source-thread delivery for every BYO runtime.
|
|
11
|
+
- Enable native coding delegation through Codex multi-agent, OpenCode task, and Claude Agent/Task, with scoped child objectives and parent review.
|
|
12
|
+
- Restore working, thinking, and typing presence through the documented REST endpoint, with per-cycle ownership, throttled refresh, and failure backoff.
|
|
13
|
+
- Remove Studio input outlines and retain a subtle keyboard-focus underline.
|
|
14
|
+
- Keep accepted coding work alive across event WebSocket disconnects. Resume confirmed runtime transport interruptions in the same queue with the original workspace, thread, and partial context; verify ownership again before restarting the model.
|
|
15
|
+
- Wait through known event-channel outages without exhausting retries. Use bounded backoff for provider-only transport failures; preserve agent final responses, cancellation, policy blocks, and guarded delivery.
|
|
16
|
+
- Refresh active cycle state in the Studio journal so long tasks remain visible after their start event leaves the bounded activity window.
|
|
17
|
+
|
|
3
18
|
## [0.23.0] — 2026-09-10
|
|
4
19
|
|
|
5
20
|
- Add local semantic and keyword search over recorded conversations with source references, bounded retention, edit replacement, and backend/agent isolation.
|
package/README.md
CHANGED
|
@@ -85,7 +85,7 @@ The backend MCP may be stateful or stateless. A successful initialize response w
|
|
|
85
85
|
|
|
86
86
|
Codex BYO agents follow the repository's normative runtime specification in `docs/CODEX_BYO_AGENT_SPEC.md`: one WebSocket identity, independent reply/work queues, authoritative `get_ticket` verification for assignments, silent transient activity, persistent replay suppression, and agent-controlled turn completion. Maintainers must run `npm run certify` before publishing.
|
|
87
87
|
|
|
88
|
-
Both `agent:mention` and organization `channel:message` events pass through the same source-recipient, ownership, and deduplication checks before any model starts. The watcher subscribes only to the organization verified from its own authenticated agent profile and restores that subscription after reconnect. Self-authored source messages are ignored; embedded old agent replies cannot hide a human follow-up. An explicit tag establishes durable ownership of that thread, so later human follow-ups remain addressed to the agent without another @mention. Messages redirected to another agent and unaddressed agent chatter stay silent, while a direct stand-down cancels queued/running work and releases ownership for that thread. Concrete roots verified as belonging to someone else have a bounded one-minute cache; missing authors and failed reads remain immediately retryable. The watcher
|
|
88
|
+
Both `agent:mention` and organization `channel:message` events pass through the same source-recipient, ownership, and deduplication checks before any model starts. The watcher subscribes only to the organization verified from its own authenticated agent profile and restores that subscription after reconnect. Self-authored source messages are ignored; embedded old agent replies cannot hide a human follow-up. An explicit tag establishes durable ownership of that thread, so later human follow-ups remain addressed to the agent without another @mention. Messages redirected to another agent and unaddressed agent chatter stay silent, while a direct stand-down cancels queued/running work and releases ownership for that thread. Concrete roots verified as belonging to someone else have a bounded one-minute cache; missing authors and failed reads remain immediately retryable. The watcher publishes working, thinking, and typing presence from actual runtime activity using the authenticated REST endpoint. It stops refreshing when work finishes or is cancelled; the app clears the indicator through its expiry timer. Accepted coding tasks publish a short native plan first; the watcher sends one acknowledgement with up to three steps in the source thread or dedicated channel. Every runtime returns a distinct final answer for guarded watcher delivery. Plans and reconnects cannot duplicate or suppress that final answer.
|
|
89
89
|
|
|
90
90
|
Ticket references follow the board UI: BYO agents use the project-scoped slug, such as `OVS-57`, in messages, comments, PR descriptions, blockers, and results. Numeric `project_id` and `ticket_id` values remain internal MCP arguments and are never used as human-facing ticket names. If an older backend omits the slug, the agent uses the ticket title rather than inventing one.
|
|
91
91
|
|
|
@@ -98,7 +98,7 @@ openvisio-agent watch --name ada --workdir ~/repo # allow REAL work on a git br
|
|
|
98
98
|
openvisio-agent stop --name ada # stop service + every ada watcher
|
|
99
99
|
```
|
|
100
100
|
|
|
101
|
-
Agents choose their tools, investigation steps, context management, and when to end a turn. Each turn receives the agent’s configured identity, role, and voice when supplied by its profile; that context is cached across restarts. Reply sessions can request their configured coding workspace through `openvisio_request_work_session`, carrying their findings, source thread, and permissions into the continuation. They do not ask you to reassign work because of an internal scheduling choice. A successful native final turn ends the cycle; it does not mark a ticket done. The agent decides when to update the board. Research, audits, and already-satisfied requests can finish without code changes, ticket mutations, or a PR. Tool failures stay visible as diagnostics without forcing another model turn or replacing the agent's explanation.
|
|
101
|
+
Agents choose their tools, investigation steps, context management, and when to end a turn. Coding agents can delegate independent subtasks through the runtime’s native sub-agent tools: Codex multi-agent is enabled, OpenCode permits task delegation, and Claude permits Agent/Task. The parent supplies scoped context and file ownership, works alongside children, reviews their results, and reports once. Delegation depends on the tools advertised by the installed runtime; it does not grant additional task or publishing authority. Each turn receives the agent’s configured identity, role, and voice when supplied by its profile; that context is cached across restarts. Reply sessions can request their configured coding workspace through `openvisio_request_work_session`, carrying their findings, source thread, and permissions into the continuation. They do not ask you to reassign work because of an internal scheduling choice. A successful native final turn ends the cycle; it does not mark a ticket done. The agent decides when to update the board. Research, audits, and already-satisfied requests can finish without code changes, ticket mutations, or a PR. Tool failures stay visible as diagnostics without forcing another model turn or replacing the agent's explanation.
|
|
102
102
|
|
|
103
103
|
The context graph records source-to-continuation relationships and recalls direct neighbors without walking into unrelated conversations. The watcher saves the agent's final response for delivery retry and avoids rerunning an unchanged assignment after restart. Claude, Codex, and OpenCode keep context within a ticket or conversation; native runtimes handle compaction. A stalled-session watchdog resets when activity arrives, so ongoing work is not stopped by a fixed task duration. Ownership, cancellation, delivery deduplication, credential injection, and repository permissions remain enforced.
|
|
104
104
|
|
|
@@ -116,6 +116,8 @@ Codex and OpenCode use Mastra ACP directly. Work sessions are retained per ticke
|
|
|
116
116
|
|
|
117
117
|
Assignments use independent FIFO entries with stable-key duplicate suppression and bounded, worktree-aware concurrency. Backlog recovery uses the same ticket verification path as WebSocket events, and each queued ticket is checked again before a model starts. MCP calls have a 20-second deadline including response bodies; tool discovery is cached and paginated. Codex MCP failures retain a bounded, credential-redacted reason in the watcher log. Cancellation targets only its ACP session. Credentials and replay state use atomic file replacement; Mastra persists contextual memory in libSQL.
|
|
118
118
|
|
|
119
|
+
An event WebSocket disconnect does not cancel accepted coding work. If the native coding runtime also reports a transport interruption, its queue entry retains the source request, workspace, and partial output. It waits for a known event-channel outage to reconnect, then verifies the ticket again and asks the agent to inspect existing work and uncertain remote writes before continuing. Provider-only interruptions use up to four retries (2, 8, 30, and 60 seconds); repeated failure then reports a blocker. Agent final responses, optional tool errors, explicit cancellation, inactivity timeouts, quota errors, and policy denials do not trigger this recovery. Coordination requests are not automatically replayed. Keep the watcher running during an outage; this recovery retains in-flight context in that watcher, not across process termination.
|
|
120
|
+
|
|
119
121
|
See `docs/BYO_PERFORMANCE_AUDIT_2026-09-08.md` in the repository for the audit results and remaining live-validation limits. Local certification does not certify a deployed agent's behavior.
|
|
120
122
|
|
|
121
123
|
`--install` sets up a background service (launchd on macOS, systemd `--user` on Linux) that runs `watch` and restarts on login. Logs go to `~/.openvisio/<agent>.log` (macOS) or `journalctl --user -u openvisio-<agent>` (Linux).
|
package/USER_GUIDE.md
CHANGED
|
@@ -76,6 +76,10 @@ Stopping a watcher interrupts its active work. When you are ready to resume, run
|
|
|
76
76
|
|
|
77
77
|
Replies stay in their source thread. New completion messages use the agent’s dedicated channel. Check the ticket and any linked pull request for delivered results. A progress message describes ongoing work; wait for the final result or blocker.
|
|
78
78
|
|
|
79
|
+
For accepted coding tasks, the agent starts by publishing a short plan. You receive one acknowledgement with up to three steps, followed later by its result. The agent can delegate independent pieces to native sub-agents and remains responsible for checking their work. Thinking, working, and typing indicators follow runtime activity; they expire after work stops. A reconnect does not repeat the plan or cancel healthy work.
|
|
80
|
+
|
|
81
|
+
When a conversation turns into coding work, the agent includes its acknowledgement and two or three next steps in the workspace handoff. They are posted to the original channel thread before coding starts. Private continuation context stays internal, and the work session does not send a second pickup message.
|
|
82
|
+
|
|
79
83
|
## Open Agent Studio
|
|
80
84
|
|
|
81
85
|
In the app, choose **Agents → Open Agent Studio**. Updated backend watchers start Studio automatically. Agent chat replies can also show this button and open the relevant agent’s settings directly.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openvisio-agent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Connect Claude Code, Codex, or OpenCode to an OpenVisio team
|
|
3
|
+
"version": "0.24.1",
|
|
4
|
+
"description": "Connect Claude Code, Codex, or OpenCode to an OpenVisio team \u2014 MCP tools + optional autonomy \u2014 in one command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"openvisio-agent": "bin/cli.mjs"
|
|
@@ -321,7 +321,7 @@ for (const agent of ['codex', 'opencode']) for (const mode of modes) for (const
|
|
|
321
321
|
}
|
|
322
322
|
if (agent === 'opencode') {
|
|
323
323
|
const value = JSON.parse(config.env.OPENCODE_CONFIG_CONTENT)
|
|
324
|
-
if (mode.canCode) assert.
|
|
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
|
package/src/runtime-control.mjs
CHANGED
|
@@ -4,18 +4,25 @@ export function runtimeControlTools({ canCode = false, workspaceAvailable = fals
|
|
|
4
4
|
if (canCode || !workspaceAvailable) return []
|
|
5
5
|
return [{
|
|
6
6
|
name: WORK_SESSION_TOOL,
|
|
7
|
-
description: 'Continue this same request in your configured coding workspace. Use when investigation reveals that local execution or edits are needed. You remain the same agent and retain the original task, recipient, and permissions. Supply
|
|
8
|
-
inputSchema: { type: 'object', properties: {
|
|
7
|
+
description: 'Continue this same request in your configured coding workspace. Use when investigation reveals that local execution or edits are needed. You remain the same agent and retain the original task, recipient, and permissions. Supply private continuation context plus a teammate-facing acknowledgement and 2-3 concrete plan steps. The watcher posts that acknowledgement and plan in the original channel/thread before starting your work session, then delivers your eventual result separately. End this turn after requesting continuation; do not repeat the acknowledgement or ask for reassignment.',
|
|
8
|
+
inputSchema: { type: 'object', properties: {
|
|
9
|
+
context: { type: 'string', minLength: 1, maxLength: 12000, description: 'Private findings, decisions, remaining work, and context for your continuation. This field is not posted to chat.' },
|
|
10
|
+
acknowledgement: { type: 'string', minLength: 1, maxLength: 1200, description: 'A short, natural first-person acknowledgement for the teammate. Say what you have accepted; do not claim completion.' },
|
|
11
|
+
plan: { type: 'array', minItems: 2, maxItems: 3, items: { type: 'string', minLength: 1, maxLength: 240 }, description: '2-3 concrete next steps to post alongside your acknowledgement before doing the work.' },
|
|
12
|
+
}, required: ['context', 'acknowledgement', 'plan'], additionalProperties: false },
|
|
9
13
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
10
14
|
}]
|
|
11
15
|
}
|
|
12
16
|
|
|
13
17
|
export function requestWorkSession(args, capabilities) {
|
|
14
18
|
if (!runtimeControlTools(capabilities).length) throw new Error('No additional coding workspace is available in this session')
|
|
15
|
-
if (!args || Object.keys(args).some((key) =>
|
|
16
|
-
|
|
19
|
+
if (!args || Object.keys(args).some((key) => !['context', 'acknowledgement', 'plan'].includes(key)) || typeof args.context !== 'string' || !args.context.trim() || args.context.length > 12000) throw new Error('Provide a nonempty context string of at most 12000 characters and only the advertised acknowledgement and plan fields')
|
|
20
|
+
if (typeof args.acknowledgement !== 'string' || !args.acknowledgement.trim() || args.acknowledgement.length > 1200 || !validPlan(args.plan)) throw new Error('Include a short acknowledgement and 2-3 nonempty plan steps of at most 240 characters each for delivery to the original conversation')
|
|
21
|
+
return { content: [{ type: 'text', text: JSON.stringify({ openvisioControl: { action: 'request_work_session', context: args.context.trim(), acknowledgement: args.acknowledgement.trim(), plan: args.plan.map(step => step.trim()) } }) }] }
|
|
17
22
|
}
|
|
18
23
|
|
|
24
|
+
const validPlan = (plan) => Array.isArray(plan) && plan.length >= 2 && plan.length <= 3 && plan.every(step => typeof step === 'string' && step.trim() && step.length <= 240)
|
|
25
|
+
|
|
19
26
|
// Only inspect outputs of our named local control tool, never arbitrary model
|
|
20
27
|
// prose, commands, ticket text, or repository content.
|
|
21
28
|
export function workSessionRequest(output, depth = 0) {
|
|
@@ -28,7 +35,11 @@ export function workSessionRequest(output, depth = 0) {
|
|
|
28
35
|
return null
|
|
29
36
|
}
|
|
30
37
|
const request = output.openvisioControl
|
|
31
|
-
if (request?.action === 'request_work_session' && typeof request.context === 'string' && request.context.trim() && request.context.length <= 12000) return {
|
|
38
|
+
if (request?.action === 'request_work_session' && typeof request.context === 'string' && request.context.trim() && request.context.length <= 12000) return {
|
|
39
|
+
context: request.context.trim(),
|
|
40
|
+
...(typeof request.acknowledgement === 'string' && request.acknowledgement.trim() && request.acknowledgement.length <= 1200 ? { acknowledgement: request.acknowledgement.trim() } : {}),
|
|
41
|
+
...(validPlan(request.plan) ? { plan: request.plan.map(step => step.trim()) } : {}),
|
|
42
|
+
}
|
|
32
43
|
for (const key of ['content', 'text', 'result', 'structuredContent']) {
|
|
33
44
|
const nested = workSessionRequest(output[key], depth + 1)
|
|
34
45
|
if (nested) return nested
|
package/src/studio-server.mjs
CHANGED
|
@@ -105,14 +105,19 @@ export async function readStudioSnapshot({ stateDir, now = Date.now }) {
|
|
|
105
105
|
const age = now() - Date.parse(event.timestamp)
|
|
106
106
|
const online = age >= -5000 && age <= FRESH_MS && event.type !== 'runtime.stopped' && pidAlive(event.pid)
|
|
107
107
|
const existing = agentsById.get(event.agent.identifier)
|
|
108
|
-
agentsById.set(event.agent.identifier, { ...existing, id: event.agent.identifier, identifier: event.agent.identifier, slug: event.agent.slug || event.agent.identifier, name: existing?.name || event.agent.slug || event.agent.identifier, provider: event.agent.provider || 'unknown', runId: event.runId, pid: Number.isSafeInteger(event.pid) ? event.pid : null, watcherAlive: online, status: online ? 'online' : 'offline', lastSeen: event.timestamp, lastEventType: event.type })
|
|
108
|
+
agentsById.set(event.agent.identifier, { ...existing, id: event.agent.identifier, identifier: event.agent.identifier, slug: existing?.slug || event.agent.slug || event.agent.identifier, name: existing?.name || event.agent.slug || event.agent.identifier, provider: event.agent.provider || 'unknown', runId: event.runId, pid: Number.isSafeInteger(event.pid) ? event.pid : null, watcherAlive: online, status: online ? 'online' : 'offline', lastSeen: event.timestamp, lastEventType: event.type })
|
|
109
109
|
}
|
|
110
|
-
const agents = [...agentsById.values()]
|
|
110
|
+
const agents = [...agentsById.values()]
|
|
111
111
|
for (const agent of agents) {
|
|
112
|
+
const profileEvent = events.findLast(event => event.agent.identifier === agent.identifier && (event.type === 'agent.profile' || event.type === 'watcher.heartbeat' && event.data?.profile))
|
|
113
|
+
const profile = profileEvent?.type === 'agent.profile' ? profileEvent.data : profileEvent?.data?.profile
|
|
114
|
+
if (typeof profile?.name === 'string' && profile.name.trim()) agent.name = profile.name.trim()
|
|
115
|
+
if (typeof profile?.avatarSeed === 'string' && profile.avatarSeed) agent.avatarSeed = profile.avatarSeed
|
|
112
116
|
const applied = events.findLast(event => event.agent.identifier === agent.identifier && event.runId === agent.runId && typeof event.data?.modelSettingsRevision === 'string')
|
|
113
117
|
agent.appliedModelRevision = applied?.data.modelSettingsRevision || ''
|
|
114
118
|
agent.modelControlsSupported = !!applied
|
|
115
119
|
}
|
|
120
|
+
agents.sort((a, b) => a.name.localeCompare(b.name))
|
|
116
121
|
return { schemaVersion: 1, demo: false, generatedAt: new Date(now()).toISOString(), agents, events: events.slice(-MAX_EVENTS), limits: { maxEvents: MAX_EVENTS, maxFiles: MAX_FILES, maxTotalBytes: MAX_TOTAL_BYTES }, warnings }
|
|
117
122
|
}
|
|
118
123
|
|
|
@@ -219,7 +224,7 @@ export async function startStudioServer({ stateDir, host = '127.0.0.1', port = 4
|
|
|
219
224
|
sendSnapshot(res, await snapshot())
|
|
220
225
|
return
|
|
221
226
|
}
|
|
222
|
-
const file = ({ '/': 'index.html', '/index.html': 'index.html', '/guide': 'guide.html', '/guide.html': 'guide.html', '/style.css': 'style.css', '/app.mjs': 'app.mjs', '/satoshi-400.woff2': 'satoshi-400.woff2', '/satoshi-500.woff2': 'satoshi-500.woff2', '/openvisio.svg': 'openvisio.svg' })[path]
|
|
227
|
+
const file = ({ '/': 'index.html', '/index.html': 'index.html', '/guide': 'guide.html', '/guide.html': 'guide.html', '/style.css': 'style.css', '/app.mjs': 'app.mjs', '/bot-avatar.mjs': 'bot-avatar.mjs', '/satoshi-400.woff2': 'satoshi-400.woff2', '/satoshi-500.woff2': 'satoshi-500.woff2', '/openvisio.svg': 'openvisio.svg' })[path]
|
|
223
228
|
if (!file) { res.writeHead(404); res.end('Not found'); return }
|
|
224
229
|
const target = await realpath(resolve(root, file))
|
|
225
230
|
const rel = relative(root, target)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Only runtime/transport diagnostics belong here. Agent prose and failed
|
|
2
|
+
// optional tool calls must never turn a successful final response into a retry.
|
|
3
|
+
const transportCodes = new Set(['ECONNRESET', 'ECONNREFUSED', 'ECONNABORTED', 'EPIPE', 'ETIMEDOUT', 'ENETDOWN', 'ENETUNREACH', 'EHOSTUNREACH', 'EAI_AGAIN', 'UND_ERR_SOCKET', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT'])
|
|
4
|
+
|
|
5
|
+
export function isTransportInterruption(error) {
|
|
6
|
+
const visited = new Set()
|
|
7
|
+
const inspect = (value) => {
|
|
8
|
+
if (!value || visited.has(value)) return false
|
|
9
|
+
visited.add(value)
|
|
10
|
+
if (typeof value === 'object') {
|
|
11
|
+
if (transportCodes.has(String(value.code || '').toUpperCase())) return true
|
|
12
|
+
return inspect(value.message) || inspect(value.cause) || inspect(value.data) || (Array.isArray(value.errors) && value.errors.some(inspect))
|
|
13
|
+
}
|
|
14
|
+
if (typeof value !== 'string') return false
|
|
15
|
+
return /\b(?:ECONNRESET|ECONNREFUSED|EPIPE|ENETUNREACH|EAI_AGAIN|UND_ERR_SOCKET)\b|socket hang up|stream disconnected before completion|(?:connection|websocket|transport) (?:was |is |unexpectedly )?(?:closed|lost|terminated)|network connection (?:lost|failed)/i.test(value)
|
|
16
|
+
}
|
|
17
|
+
return inspect(error)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function interruptedRuntimeResult(result) {
|
|
21
|
+
if (!result || ['ok', 'success', 'canceled', 'cancelled', 'blocked', 'rate_limited', 'timeout'].includes(result.subtype) || result.policyBlock) return false
|
|
22
|
+
return result.transportInterrupted === true || isTransportInterruption(result.error) || isTransportInterruption({ errors: result.errors })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const transportRecoveryDelays = [2_000, 8_000, 30_000, 60_000]
|
package/src/watch.mjs
CHANGED
|
@@ -22,6 +22,8 @@ import { repositoryHasPrPushAuthorization, configurePrPublishing } from './pr-pu
|
|
|
22
22
|
import { createMcpHttpClient } from './mcp-http.mjs'
|
|
23
23
|
import { createCycleQueue } from './cycle-queue.mjs'
|
|
24
24
|
import { createRunnerPool } from './runner-pool.mjs'
|
|
25
|
+
import { createActivityReporter } from './activity-reporter.mjs'
|
|
26
|
+
import { interruptedRuntimeResult, isTransportInterruption, transportRecoveryDelays } from './transport-recovery.mjs'
|
|
25
27
|
import { mapConcurrent } from './concurrency.mjs'
|
|
26
28
|
import { releaseAuthorizedPause } from './authorization-resume.mjs'
|
|
27
29
|
import { dedicatedChannel, repliesAfterSource } from './channel-routing.mjs'
|
|
@@ -79,7 +81,7 @@ const REPLY_DISCIPLINE = [
|
|
|
79
81
|
' • FIRST-PERSON VOICE. Speak as yourself: use “I”, “I\'m”, and “my”. Never refer to yourself by your agent name or in the third person, and never restate your own name in introductions, acknowledgements, progress, blockers, or results. The app already shows who sent the message. Sound like a warm, accountable teammate, not a status bot.',
|
|
80
82
|
' • IS IT FOR YOU? Act ONLY on messages addressed to YOU — an @mention of your exact name, a direct question to you, or a reply to something YOU said or did. If a DIFFERENT agent or person was @mentioned or asked to do something, STAY OUT: do not answer for them and do not pick up their task. When it is not yours, posting nothing is the correct move.',
|
|
81
83
|
' • EVENT NAMES ARE NOT OWNERSHIP. A transport may wake you for activity in a thread you once joined. Trust only the watcher\'s verified recipient decision for the current source message; never infer that every thread update is yours.',
|
|
82
|
-
' •
|
|
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,26 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
604
617
|
// Activity belongs to an individual cycle. Independent ticket workers must
|
|
605
618
|
// not clear or overwrite each other's status targets.
|
|
606
619
|
const replyStatusTargets = new Set()
|
|
607
|
-
|
|
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
|
+
// Reply sessions may discover work themselves. Keep their native plan for
|
|
634
|
+
// an explicit continuation, without announcing plans for ordinary answers.
|
|
635
|
+
if (type === 'plan.updated') live.control.planEntries = data.entries
|
|
636
|
+
if (type === 'plan.updated' && live.data.kind === 'full' && data.entries?.some(entry => typeof entry?.content === 'string' && entry.content.trim()) && !live.control.planDelivery) {
|
|
637
|
+
live.control.planDelivery = trackOperation(publishWorkPlan(live, data.entries)).catch(error => log('Task plan delivery unavailable: ' + (error?.message || error)))
|
|
638
|
+
}
|
|
639
|
+
}
|
|
611
640
|
const canCode = !!workdir
|
|
612
641
|
// The Mastra bridge authenticates per-CALL: every openvisio-team tool needs
|
|
613
642
|
// agent_identifier + agent_api_key as arguments. Hand them over up front.
|
|
@@ -618,7 +647,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
618
647
|
const systemPrompt = (canCode ? CODE_CHARTER : CHAT_CHARTER) + '\n\n' + credNote
|
|
619
648
|
const runnerOptions = {
|
|
620
649
|
claude, agent, mcpUrl, mcpHeaders: { 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier },
|
|
621
|
-
cfgKey: identifier, mcpConfig, workdir, workspaceAvailable: canCode, log, debug, model, systemPrompt, onEvent:
|
|
650
|
+
cfgKey: identifier, mcpConfig, workdir, workspaceAvailable: canCode, log, debug, model, systemPrompt, onEvent: runtimeEvent,
|
|
622
651
|
getContextEnvironment: () => contextService.environment(),
|
|
623
652
|
}
|
|
624
653
|
// One watcher and one WS subscription, with a bounded pool of isolated coding
|
|
@@ -626,6 +655,9 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
626
655
|
// concurrently behind the same delivery guards.
|
|
627
656
|
const MAX_CONCURRENT_WORKERS = 3
|
|
628
657
|
const activeWorkRunners = new Set()
|
|
658
|
+
const recoveryWaiters = new Set()
|
|
659
|
+
const recoveryDelays = dependencies.transportRecoveryDelays || transportRecoveryDelays
|
|
660
|
+
let eventConnection = 'unknown'
|
|
629
661
|
const createWorkRunner = (control, workerWorkdir = '') => makeRunner({
|
|
630
662
|
...runnerOptions,
|
|
631
663
|
...(workerWorkdir ? { workdir: workerWorkdir } : {}),
|
|
@@ -652,7 +684,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
652
684
|
const fullPrompt = (canCode ? CODE_FULL + codexPushGuide : 'Handle the supplied verified backend ticket with the available OpenVisio tools. Update or comment on the ticket as requested, do not claim repository work in chat-only mode, and stop after the verified action.') + '\n\n' + backendToolRule + studioGuide
|
|
653
685
|
const fastPrompt = (canCode ? CODE_FAST : CYCLE_FAST) + '\n\n' + backendToolRule + studioGuide
|
|
654
686
|
const coordinatePrompt = COORDINATE + '\n\n' + backendToolRule + studioGuide
|
|
655
|
-
const guardedReplyPrompt = `WATCHER-DELIVERED REPLY: the watcher has already verified that the current source message is addressed to you. Your current OpenVisio agent identifier is "${identifier}"; do not call list_agents merely to rediscover yourself. Return the final reply for watcher delivery instead of calling post_message for the same result. Discover tools and retrieve additional context as needed. OpenVisio team-state tools are allowed: when the answer depends on live projects, assignments, tickets, or status, use list_projects/list_tasks/get_ticket as needed and never ask the teammate for a slug that those tools can resolve. A failed read-only discovery call is not a completed action and must not be described as an intervention-level blocker; continue with available live data or state the narrow fact you could not verify. Return one natural, context-specific final response with enough detail to address the request. Do not echo the request
|
|
687
|
+
const guardedReplyPrompt = `WATCHER-DELIVERED REPLY: the watcher has already verified that the current source message is addressed to you. Your current OpenVisio agent identifier is "${identifier}"; do not call list_agents merely to rediscover yourself. Return the final reply for watcher delivery instead of calling post_message for the same result. Discover tools and retrieve additional context as needed. OpenVisio team-state tools are allowed: when the answer depends on live projects, assignments, tickets, or status, use list_projects/list_tasks/get_ticket as needed and never ask the teammate for a slug that those tools can resolve. A failed read-only discovery call is not a completed action and must not be described as an intervention-level blocker; continue with available live data or state the narrow fact you could not verify. Return one natural, context-specific final response with enough detail to address the request. Do not echo the request or replace the final answer with a plan. Task acknowledgements are delivered separately from your native plan; ordinary questions need only their answer. If the supplied context says the work was completed, lead with the verified result; if it is blocked, name only the real blocker and next action.` + '\n\n' + backendToolRule + studioGuide
|
|
656
688
|
// Live model state — changeable at runtime by the in-chat `/model` command.
|
|
657
689
|
// codeModel drives full/sweep cycles; chatModel (if set) the lighter fast/intro
|
|
658
690
|
// ones, so routine chatter can run cheaper than real code work.
|
|
@@ -681,7 +713,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
681
713
|
const runner = workRunners.acquire(sessionKey, { control: item.control, workdir: item.workdir })
|
|
682
714
|
item.control.runner = runner
|
|
683
715
|
activeWorkRunners.add(runner)
|
|
684
|
-
try { return await cycleContext.run({ cycleId: item.control.cycleId }, () =>
|
|
716
|
+
try { return await cycleContext.run({ cycleId: item.control.cycleId }, () => executeRecoverableCycle(item, runner)) }
|
|
685
717
|
finally { workRunners.release(sessionKey); activeWorkRunners.delete(runner); item.control.runner = null }
|
|
686
718
|
},
|
|
687
719
|
onError: (error, item) => {
|
|
@@ -743,7 +775,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
743
775
|
const finishedTaskVersions = new Map(Array.isArray(replayState.finishedTaskVersions) ? replayState.finishedTaskVersions : [])
|
|
744
776
|
const reportedCompletions = new Set(Array.isArray(replayState.reportedCompletions) ? replayState.reportedCompletions : [])
|
|
745
777
|
const reportedTaskComments = new Set(Array.isArray(replayState.reportedTaskComments) ? replayState.reportedTaskComments : [])
|
|
746
|
-
//
|
|
778
|
+
// Unrecoverable runtime failures are held at the ticket revision that produced them.
|
|
747
779
|
// This is distinct from a policy block: any later human ticket change resumes
|
|
748
780
|
// the work, but reconnects and the watcher's own blocker update do not.
|
|
749
781
|
const failedTaskVersions = new Map(replayState.completionPolicy === 'agent' && Array.isArray(replayState.failedTaskVersions) ? replayState.failedTaskVersions : [])
|
|
@@ -849,13 +881,17 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
849
881
|
let selfAgentId = null
|
|
850
882
|
let selfOrganizationId = null
|
|
851
883
|
let identityContext = memory.get?.(`identity:${identifier}`)?.meta?.context || agentProfileContext({}, identifier)
|
|
884
|
+
let agentAppearance = memory.get?.(`identity:${identifier}`)?.meta?.appearance || null
|
|
885
|
+
if (agentAppearance) emit('agent.profile', agentAppearance)
|
|
852
886
|
const selfAliases = new Set([slug, identifier].map((value) => String(value || '').toLowerCase()).filter(Boolean))
|
|
853
887
|
const rememberSelfAgent = (self) => {
|
|
854
888
|
if (!self || typeof self !== 'object' || String(self.identifier || self.slug || '') !== identifier) return
|
|
855
889
|
if (validBackendId(self.id)) selfAgentId = Number(self.id)
|
|
856
890
|
for (const alias of [self.name, self.identifier, self.slug]) if (alias) selfAliases.add(String(alias).toLowerCase())
|
|
857
891
|
identityContext = agentProfileContext(self, identifier)
|
|
858
|
-
|
|
892
|
+
const appearance = { name: String(self.name || identifier).slice(0, 160), avatarSeed: String(self.slug || self.identifier || identifier).slice(0, 240) }
|
|
893
|
+
if (JSON.stringify(appearance) !== JSON.stringify(agentAppearance)) { agentAppearance = appearance; emit('agent.profile', appearance) }
|
|
894
|
+
memory.remember({ key: `identity:${identifier}`, kind: 'identity', state: 'configured', summary: self.name || identifier, meta: { context: identityContext, appearance } })
|
|
859
895
|
const organizationId = self.organization_id ?? self.organizationId
|
|
860
896
|
if (validBackendId(organizationId)) {
|
|
861
897
|
selfOrganizationId = Number(organizationId)
|
|
@@ -1030,6 +1066,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1030
1066
|
for (const [laneName, queue] of Object.entries(queues)) {
|
|
1031
1067
|
queue.cancel((item) => sameDeliveryThread(item.delivery, channelId, parentId), (item) => {
|
|
1032
1068
|
item.control.cancelled = true
|
|
1069
|
+
item.control.wakeRecovery?.()
|
|
1033
1070
|
log(`${laneName} lane cancelled by a newer redirect/stand-down in thread ${parentId}`)
|
|
1034
1071
|
void item.control.runner?.cancelCurrent?.('thread-cancelled')
|
|
1035
1072
|
})
|
|
@@ -1058,7 +1095,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1058
1095
|
identity: () => ({ id: selfAgentId, identifier, slug, name: slug }),
|
|
1059
1096
|
load: async (cid, root) => toolData(await callMcpTool('list_message_thread', { channel_id: Number(cid), message_id: Number(root) })),
|
|
1060
1097
|
})
|
|
1061
|
-
const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '', sourceMessageId, allowCancelled = false }) => {
|
|
1098
|
+
const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '', sourceMessageId, allowCancelled = false, isCancelled = () => false }) => {
|
|
1062
1099
|
if (stopping) throw new Error('Watcher is stopping')
|
|
1063
1100
|
const deliveryKey = String(key || '')
|
|
1064
1101
|
if (parentId == null) {
|
|
@@ -1075,6 +1112,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1075
1112
|
if (messageDeliveries.has(deliveryKey)) return messageDeliveries.get(deliveryKey)
|
|
1076
1113
|
const controlKey = threadControlKey(channelId, parentId)
|
|
1077
1114
|
const suppressCancelled = () => {
|
|
1115
|
+
if (isCancelled()) return { posted: false, reason: 'cycle-cancelled' }
|
|
1078
1116
|
if (memory.has(deliveryKey, 'suppressed')) return { posted: false, reason: 'source-cancelled' }
|
|
1079
1117
|
if (allowCancelled || !controlKey || !memory.has(controlKey, 'cancelled')) return null
|
|
1080
1118
|
pendingReplyDeliveries.delete(deliveryKey)
|
|
@@ -1094,7 +1132,8 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1094
1132
|
const run = (async () => {
|
|
1095
1133
|
if (parentId != null) {
|
|
1096
1134
|
const live = toolData(await callMcpTool('list_message_thread', { channel_id: Number(channelId), message_id: Number(parentId) }))
|
|
1097
|
-
const
|
|
1135
|
+
const plans = skipIfAnyAgentReply ? memory.recall({ channelId: Number(channelId), threadId: Number(parentId) }, 1000).filter(node => node.kind === 'work-plan' && node.meta?.sourceKey === sourceKey) : []
|
|
1136
|
+
const rendered = repliesAfterSource(renderedAgentMessages(live, { id: selfAgentId, identifier, slug, name: slug }), sourceMessageId).filter(row => !plans.some(plan => normalizeRenderedMessageText(plan.summary) === normalizeRenderedMessageText(row.content)))
|
|
1098
1137
|
const duplicate = rendered.some((row) => normalizeRenderedMessageText(row.content) === normalizeRenderedMessageText(message))
|
|
1099
1138
|
if (duplicate || (skipIfAnyAgentReply && rendered.length)) {
|
|
1100
1139
|
deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
|
|
@@ -1133,6 +1172,27 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1133
1172
|
return run
|
|
1134
1173
|
}
|
|
1135
1174
|
|
|
1175
|
+
async function publishWorkPlan(live, entries, acknowledgement = '') {
|
|
1176
|
+
const { control, delivery, taskRef } = live
|
|
1177
|
+
const steps = (Array.isArray(entries) ? entries : []).filter(entry => entry && typeof entry.content === 'string' && entry.content.trim()).slice(0, 3).map(entry => entry.content.trim().slice(0, 240))
|
|
1178
|
+
if (!steps.length || control.cancelled || !control.planKey || memory.has(control.planKey)) return
|
|
1179
|
+
const introduction = String(acknowledgement || '').trim().slice(0, 1200) || (control.taskLabel ? `I'm on ${control.taskLabel}.` : "I'll take this on.")
|
|
1180
|
+
const content = `${introduction}\n\n${steps.map((step, index) => `${index + 1}. ${step}`).join('\n')}`
|
|
1181
|
+
const channelId = delivery?.channelId ?? taskRef?.channelId ?? await projectStatusChannel(taskRef?.projectId)
|
|
1182
|
+
if (channelId == null || control.cancelled) return
|
|
1183
|
+
const refs = { channelId: Number(channelId), ...(delivery?.parentId != null ? { threadId: Number(delivery.parentId) } : {}) }
|
|
1184
|
+
// Remember intent before a potentially ambiguous write. A later final reply
|
|
1185
|
+
// must not be mistaken for a duplicate of this plan, even after reconnect.
|
|
1186
|
+
memory.remember({ key: control.planKey, kind: 'work-plan', state: 'attempted', summary: content, refs, meta: { sourceKey: delivery?.sourceKey || '' } })
|
|
1187
|
+
try {
|
|
1188
|
+
await postMessageOnce({ key: control.planKey + ':delivery', channelId, parentId: delivery?.parentId, projectId: taskRef?.projectId, sourceKey: delivery?.sourceKey, sourceMessageId: delivery?.sourceMessageId, content, isCancelled: () => control.cancelled })
|
|
1189
|
+
} finally {
|
|
1190
|
+
// An acknowledgement is only useful before work completes. Never leave a
|
|
1191
|
+
// delayed pickup message queued behind a delivered final response.
|
|
1192
|
+
pendingReplyDeliveries.delete(control.planKey + ':delivery'); persistReplay()
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1136
1196
|
let deliveryProbeBusy = false
|
|
1137
1197
|
const flushPendingReplies = async () => {
|
|
1138
1198
|
if (stopping || deliveryProbeBusy) return
|
|
@@ -1489,21 +1549,57 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1489
1549
|
const cycleId = `${journal.runId}:cycle:${++cycleSequence}`
|
|
1490
1550
|
const control = { cycleId, outcome: 'queued', cancelled: false, runner: null, statusTargets: new Set(), enqueuedAt: performance.now() }
|
|
1491
1551
|
const cycleData = { cycleId, kind, lane: laneName, model: kind === 'full' ? codeModel : liteModel, workdir: itemWorkdir || (laneName === 'work' ? workdir : ''), ...(taskRef ? { ticket: { projectId: taskRef.projectId, ticketId: taskRef.ticketId } } : {}), ...(delivery ? { thread: { channelId: delivery.channelId, threadId: delivery.parentId } } : {}) }
|
|
1552
|
+
liveCycles.set(cycleId, { data: cycleData, control, taskRef, delivery })
|
|
1492
1553
|
emit('cycle.queued', { ...cycleData, status: 'queued' })
|
|
1493
1554
|
return queues[laneName].enqueue({ kind, context, targetChannels, taskRef, delivery, workdir: itemWorkdir, control }, key).then((result) => {
|
|
1494
1555
|
const status = control.cancelled || result?.status === 'canceled' ? 'canceled' : result?.status === 'failed' ? 'error' : control.outcome
|
|
1495
1556
|
emit(status === 'canceled' ? 'cycle.cancelled' : 'cycle.finished', { ...cycleData, status, finishedBy: control.finishedBy || 'runtime', durationMs: Math.round(performance.now() - (control.startedAt ?? control.enqueuedAt)) })
|
|
1557
|
+
liveCycles.delete(cycleId)
|
|
1558
|
+
activity.clear(cycleId)
|
|
1496
1559
|
return result
|
|
1497
1560
|
})
|
|
1498
1561
|
}
|
|
1499
1562
|
|
|
1563
|
+
async function executeRecoverableCycle(item, runner) {
|
|
1564
|
+
const { control } = item
|
|
1565
|
+
let partialOutput = ''
|
|
1566
|
+
for (let attempt = 1; !stopping && !control.cancelled; attempt++) {
|
|
1567
|
+
control.attempt = attempt
|
|
1568
|
+
let interruption
|
|
1569
|
+
const continuation = attempt === 1 ? '' : '\n\nTRANSPORT RECOVERY: The previous runtime was interrupted before an agent final response. Continue the original request in the existing workspace. Inspect current files, git state, and live task/thread history first; edits and remote writes may already have succeeded. Do not blindly repeat commits, ticket creation, comments, or other writes. Verify uncertain outcomes before acting. You decide the remaining work and when to finish.' + (partialOutput ? '\nPrevious partial output (not a final result):\n' + partialOutput : '')
|
|
1570
|
+
try {
|
|
1571
|
+
interruption = await executeCycle(item.kind, item.context + continuation, item.targetChannels, item.taskRef, item.delivery, runner, control, item.workdir)
|
|
1572
|
+
if (!interruption?.transportInterrupted) return
|
|
1573
|
+
partialOutput = String(interruption.outputText || partialOutput).slice(-12000)
|
|
1574
|
+
} catch (error) {
|
|
1575
|
+
if (stopping || control.cancelled) return
|
|
1576
|
+
if (attempt > recoveryDelays.length || !isTransportInterruption(error)) throw error
|
|
1577
|
+
}
|
|
1578
|
+
if (stopping || control.cancelled) return
|
|
1579
|
+
control.outcome = 'recovering'
|
|
1580
|
+
activity.clear(control.cycleId)
|
|
1581
|
+
const delayMs = recoveryDelays[attempt - 1]
|
|
1582
|
+
emit('cycle.recovering', { cycleId: control.cycleId, status: 'recovering', attempt, retryInMs: delayMs, reason: 'Runtime connection interrupted; preserving accepted work' })
|
|
1583
|
+
log(`coding runtime connection interrupted; resuming attempt ${attempt + 1} in ${delayMs / 1000}s or on reconnect`)
|
|
1584
|
+
await new Promise((resolve) => {
|
|
1585
|
+
const wake = () => { clearTimeout(timer); recoveryWaiters.delete(wake); control.wakeRecovery = null; resolve() }
|
|
1586
|
+
// A known event-channel outage can last longer than the retry budget.
|
|
1587
|
+
// Keep accepted work pending until reconnect instead of burning model
|
|
1588
|
+
// attempts while offline. Provider-only failures still use backoff.
|
|
1589
|
+
const timer = setTimeout(() => { if (eventConnection !== 'disconnected') wake() }, delayMs)
|
|
1590
|
+
recoveryWaiters.add(wake)
|
|
1591
|
+
control.wakeRecovery = wake
|
|
1592
|
+
})
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1500
1596
|
async function executeCycle(kind, context, targetChannels = [], taskRef = null, delivery = null, runner, control, preparedWorkdir = '') {
|
|
1501
1597
|
refreshModelSettings()
|
|
1502
1598
|
const laneName = kind === 'full' ? 'work' : 'reply'
|
|
1503
1599
|
const cycleControl = control || { cancelled: false, runner, statusTargets: new Set() }
|
|
1504
1600
|
const cycleStartedAt = performance.now()
|
|
1505
|
-
cycleControl.startedAt
|
|
1506
|
-
cycleControl.outcome = '
|
|
1601
|
+
cycleControl.startedAt ??= cycleStartedAt
|
|
1602
|
+
cycleControl.outcome = 'preflight'
|
|
1507
1603
|
emit('cycle.started', { cycleId: cycleControl.cycleId, kind, lane: laneName, status: 'preflight', model: kind === 'full' ? codeModel : liteModel, workdir: preparedWorkdir || (kind === 'full' ? workdir : ''), ...(taskRef ? { ticket: { projectId: taskRef.projectId, ticketId: taskRef.ticketId } } : {}), ...(delivery ? { thread: { channelId: delivery.channelId, threadId: delivery.parentId } } : {}), queueMs: Math.round(cycleStartedAt - (cycleControl.enqueuedAt ?? cycleStartedAt)) })
|
|
1508
1604
|
log(`cycle timing queue=${Math.round(cycleStartedAt - (cycleControl.enqueuedAt ?? cycleStartedAt))}ms kind=${kind}`)
|
|
1509
1605
|
const ctx = context ? [context] : []
|
|
@@ -1511,12 +1607,12 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1511
1607
|
let verifiedTaskRevision = ''
|
|
1512
1608
|
const ticketWorktree = preparedWorkdir || (kind === 'full' && activeTaskRef ? findTicketWorktree(workdir, activeTaskRef.ticketId) : '')
|
|
1513
1609
|
const runnerOptions = {
|
|
1514
|
-
cycleId: cycleControl.cycleId, attempt: 1,
|
|
1610
|
+
cycleId: cycleControl.cycleId, attempt: cycleControl.attempt || 1,
|
|
1515
1611
|
...(delivery?.watcherOwned ? { disabledMcpTools: ['post_message'] } : {}),
|
|
1516
1612
|
...(ticketWorktree ? { workdir: ticketWorktree } : {}),
|
|
1517
1613
|
}
|
|
1518
1614
|
const targets = [...new Set(targetChannels.filter((id) => id != null && Number.isFinite(Number(id))).map(Number))]
|
|
1519
|
-
cycleControl.statusTargets =
|
|
1615
|
+
cycleControl.statusTargets = new Set(targets)
|
|
1520
1616
|
if (laneName === 'reply') {
|
|
1521
1617
|
replyStatusTargets.clear()
|
|
1522
1618
|
for (const target of targets) replyStatusTargets.add(target)
|
|
@@ -1530,7 +1626,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1530
1626
|
try { recalled = await memory.context(memoryRefs) }
|
|
1531
1627
|
catch (error) { log('Optional history unavailable; agent can retrieve context with its tools: ' + (error?.message || error)) }
|
|
1532
1628
|
const capabilityContext = kind !== 'full' && canCode
|
|
1533
|
-
? 'YOUR CODING WORKSPACE IS AVAILABLE. If this request needs local execution or edits, call openvisio_request_work_session with the
|
|
1629
|
+
? 'YOUR CODING WORKSPACE IS AVAILABLE. If this request needs local execution or edits, call openvisio_request_work_session with private continuation context, a short teammate-facing acknowledgement, and 2-3 concrete plan steps. The watcher posts the acknowledgement and plan in this source conversation before starting the work session. Plain final text and internal context are not posted during this handoff: put your public acknowledgement and plan in the tool fields. Then end this turn. The same agent continues the same request in its coding workspace. Do not claim to be a chat-only agent or ask anyone to reassign the ticket.'
|
|
1534
1630
|
: canCode ? 'This session has your configured coding workspace. Choose the tools and context appropriate to the request.' : 'This connection has no configured local coding workspace. Use your available tools for the request; do not claim local changes you cannot perform.'
|
|
1535
1631
|
const prompt = identityContext + '\n\n' + capabilityContext + '\n\n' + (ctx.length ? ctx.join('\n') + '\n\n' : '') + (recalled ? recalled + '\n\n' : '') + baseFor(kind, delivery)
|
|
1536
1632
|
// Chat-shaped cycles (mentions/intro) may run on the cheaper chat model; code
|
|
@@ -1539,8 +1635,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1539
1635
|
const cycleModelRevision = modelSettingsRevision
|
|
1540
1636
|
const useModel = kind === 'full' ? codeModel : liteModel
|
|
1541
1637
|
log('running ' + kind + ' cycle…' + (ctx.length ? ' (' + ctx.length + ' event' + (ctx.length === 1 ? '' : 's') + ')' : '') + (useModel ? ' [' + useModel + ']' : ''))
|
|
1542
|
-
|
|
1543
|
-
// ticket transitions are the only user-visible progress signals.
|
|
1638
|
+
activity.set(cycleControl.cycleId, cycleControl.statusTargets, 'thinking')
|
|
1544
1639
|
try {
|
|
1545
1640
|
// The ticket may have been reassigned or handed to review while waiting.
|
|
1546
1641
|
// Re-check at dequeue, before any model can edit the repository.
|
|
@@ -1548,19 +1643,23 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1548
1643
|
const data = toolData(await callMcpTool('get_ticket', { project_id: activeTaskRef.projectId, ticket_id: activeTaskRef.ticketId }))
|
|
1549
1644
|
const ticket = data.ticket ?? data.task ?? data
|
|
1550
1645
|
verifiedTaskRevision = taskRevision(ticket)
|
|
1646
|
+
cycleControl.taskLabel = ticketDisplaySlug(ticket) || String(ticket.title || 'the task').slice(0, 160)
|
|
1551
1647
|
const typeSets = await taskTypeSets(activeTaskRef.projectId)
|
|
1552
1648
|
if (blockedTasks.has(`${activeTaskRef.projectId}:${activeTaskRef.ticketId}`) ||
|
|
1553
1649
|
!taskBelongsToAgent(ticket, { id: selfAgentId, identifier }) ||
|
|
1554
1650
|
taskIsCompleted(ticket, typeSets.done) || taskIsAwaitingReview(ticket, typeSets.review)) {
|
|
1555
1651
|
releaseTaskForRetry(activeTaskRef, prompt)
|
|
1652
|
+
cycleControl.outcome = 'skipped'
|
|
1556
1653
|
log('queued ticket no longer actionable; skipped before model start')
|
|
1557
1654
|
return
|
|
1558
1655
|
}
|
|
1559
1656
|
}
|
|
1560
1657
|
if (cycleControl.cancelled) return
|
|
1658
|
+
cycleControl.planKey ||= 'plan:' + (delivery?.key || `${activeTaskRef?.projectId}:${activeTaskRef?.ticketId}:${verifiedTaskRevision}`)
|
|
1561
1659
|
cycleControl.outcome = 'running'
|
|
1562
1660
|
log(`cycle timing preflight=${Math.round(performance.now() - cycleStartedAt)}ms kind=${kind}`)
|
|
1563
1661
|
const result = await runner.runCycle(prompt, useModel, runnerOptions)
|
|
1662
|
+
await cycleControl.planDelivery
|
|
1564
1663
|
cycleControl.outcome = result?.is_error ? 'error' : result?.subtype || 'error'
|
|
1565
1664
|
if (result?.timings) log(`cycle timing runtime=${JSON.stringify(result.timings)} kind=${kind}`)
|
|
1566
1665
|
refreshModelSettings()
|
|
@@ -1584,6 +1683,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1584
1683
|
return
|
|
1585
1684
|
}
|
|
1586
1685
|
if (!cycleSucceeded(result)) {
|
|
1686
|
+
if (kind === 'full' && interruptedRuntimeResult(result) && cycleControl.attempt <= recoveryDelays.length) return { ...result, transportInterrupted: true }
|
|
1587
1687
|
const outcome = result?.subtype || 'an unknown runtime error'
|
|
1588
1688
|
const notice = `I'm blocked because the ${kind === 'full' ? 'coding' : 'reply'} cycle ended with ${outcome}.${result?.subtype === 'rate_limited' ? ` The model provider is rate-limiting this request. Choose a model with available quota in Agent Studio, or retry once the limit resets.\n\n[Open Agent Studio](http://127.0.0.1:4317/#agent=${encodeURIComponent(identifier)}&settings=1)\n\n` : ' '}I'm not claiming completion.${activeTaskRef ? " I've paused this revision until the ticket changes." : ''}`
|
|
1589
1689
|
log('WORK_CYCLE_BLOCKED ' + outcome + '; publishing blocker')
|
|
@@ -1597,6 +1697,11 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1597
1697
|
// An agent-selected continuation, not a heuristic retry or a new task.
|
|
1598
1698
|
// Preserve the authoritative recipient and cancellation key; the tool
|
|
1599
1699
|
// cannot select another task, channel, identity, or permission scope.
|
|
1700
|
+
const entries = Array.isArray(result.workRequest.plan)
|
|
1701
|
+
? result.workRequest.plan.map(content => ({ content })) : cycleControl.planEntries
|
|
1702
|
+
try { await publishWorkPlan({ control: cycleControl, delivery, taskRef: activeTaskRef }, entries, result.workRequest.acknowledgement) }
|
|
1703
|
+
catch (error) { log('Task plan delivery unavailable: ' + (error?.message || error)) }
|
|
1704
|
+
if (cycleControl.cancelled || stopping) return
|
|
1600
1705
|
cycleControl.outcome = 'continued'
|
|
1601
1706
|
cycleControl.finishedBy = 'agent'
|
|
1602
1707
|
const continuation = String(result.workRequest.context || '').slice(0, 12000)
|
|
@@ -1640,6 +1745,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1640
1745
|
}
|
|
1641
1746
|
}
|
|
1642
1747
|
} finally {
|
|
1748
|
+
activity.clear(cycleControl.cycleId)
|
|
1643
1749
|
cycleControl.statusTargets.clear()
|
|
1644
1750
|
}
|
|
1645
1751
|
}
|
|
@@ -1702,6 +1808,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1702
1808
|
for (const [name, queue] of Object.entries(queues)) {
|
|
1703
1809
|
queue.cancel((item) => String(item.taskRef?.projectId) === String(projectId) && String(item.taskRef?.ticketId) === String(ticketId), (item) => {
|
|
1704
1810
|
item.control.cancelled = true
|
|
1811
|
+
item.control.wakeRecovery?.()
|
|
1705
1812
|
void item.control.runner?.cancelCurrent?.()
|
|
1706
1813
|
})
|
|
1707
1814
|
}
|
|
@@ -1870,10 +1977,10 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1870
1977
|
try { contextService.observe({ name: 'list_message_thread', args: { channel_id: cid, message_id: threadRoot }, result: { ...msg, id: mid, content: String(msg.content || msg.body || msg.text || '') } }) }
|
|
1871
1978
|
catch { /* optional conversation indexing */ }
|
|
1872
1979
|
// Every runtime carries the source thread through the queue so a later
|
|
1873
|
-
// redirect can cancel its process.
|
|
1874
|
-
//
|
|
1980
|
+
// redirect can cancel its process. The watcher delivers the final answer
|
|
1981
|
+
// for every runtime after an authoritative last-moment thread check.
|
|
1875
1982
|
const conversationDelivery = (stage = 'reply', skipIfAnyAgentReply = stage !== 'result') => cid != null
|
|
1876
|
-
? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey, sourceMessageId: mid, watcherOwned:
|
|
1983
|
+
? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey, sourceMessageId: mid, watcherOwned: true }
|
|
1877
1984
|
: null
|
|
1878
1985
|
// Under-the-hood model control from chat (view / switch the model the agent runs).
|
|
1879
1986
|
const mcmd = cid != null ? parseModelCmd(text) : null
|
|
@@ -1958,7 +2065,19 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1958
2065
|
// recover channel mentions even when the backend omits targeted dispatch.
|
|
1959
2066
|
// Both event types share recipient, ownership and deduplication guards.
|
|
1960
2067
|
log('up — backend WS watcher on ' + wsUrl + (canCode ? ' [code: ' + workdir + ']' : ''))
|
|
1961
|
-
handle = (dependencies.connect || connectAgentWs)({ wsUrl, apiKey, identifier, onEvent,
|
|
2068
|
+
handle = (dependencies.connect || connectAgentWs)({ wsUrl, apiKey, identifier, onEvent,
|
|
2069
|
+
onDisconnect: () => {
|
|
2070
|
+
eventConnection = 'disconnected'
|
|
2071
|
+
emit('connection.disconnected', { status: 'disconnected', reason: 'Event connection lost; accepted work remains running' })
|
|
2072
|
+
},
|
|
2073
|
+
onConnect: () => {
|
|
2074
|
+
if (stopping) return
|
|
2075
|
+
eventConnection = 'connected'
|
|
2076
|
+
activity.pulse()
|
|
2077
|
+
emit('connection.connected', { status: 'connected' })
|
|
2078
|
+
for (const wake of [...recoveryWaiters]) wake()
|
|
2079
|
+
if (autoStart) setTimeout(() => { if (!stopping) void reconcileBacklog() }, 250)
|
|
2080
|
+
}, log })
|
|
1962
2081
|
|
|
1963
2082
|
let authorizationProbeBusy = false
|
|
1964
2083
|
const legacyAuthorizationChecks = new Set()
|
|
@@ -1999,7 +2118,14 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1999
2118
|
const modelSettingsTimer = autoStart ? setInterval(refreshModelSettings, 2_000) : null
|
|
2000
2119
|
modelSettingsTimer?.unref?.()
|
|
2001
2120
|
emit('models.updated', { model: codeModel, chatModel: liteModel, modelSettingsRevision })
|
|
2002
|
-
const heartbeatTimer = autoStart ? setInterval(() =>
|
|
2121
|
+
const heartbeatTimer = autoStart ? setInterval(() => {
|
|
2122
|
+
emit('watcher.heartbeat', { status: 'running', active: queues.work.activeSize + queues.reply.activeSize, pending: queues.work.size + queues.reply.size, model: codeModel, chatModel: liteModel, modelSettingsRevision, workdir: workdir || '', ...(agentAppearance ? { profile: agentAppearance } : {}) })
|
|
2123
|
+
// The Studio reads a bounded journal tail. Re-state current queue ownership
|
|
2124
|
+
// so a long-running cycle stays visible after its start event rotates out.
|
|
2125
|
+
for (const { data, control } of liveCycles.values()) {
|
|
2126
|
+
if (!control.cancelled) emit('cycle.snapshot', { ...data, status: control.outcome, attempt: control.attempt || 1 })
|
|
2127
|
+
}
|
|
2128
|
+
}, 15_000) : null
|
|
2003
2129
|
heartbeatTimer?.unref?.()
|
|
2004
2130
|
let introTimer = null, taskProbeStartTimer = null, taskProbeTimer = null
|
|
2005
2131
|
if (autoStart && (mcpConfig || mcpUrl)) {
|
|
@@ -2039,12 +2165,13 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2039
2165
|
if (taskProbeTimer) clearInterval(taskProbeTimer)
|
|
2040
2166
|
try { handle?.close() } catch { /* already disconnected */ }
|
|
2041
2167
|
for (const queue of Object.values(queues)) {
|
|
2042
|
-
queue.cancel(() => true, (item) => { item.control.cancelled = true })
|
|
2168
|
+
queue.cancel(() => true, (item) => { item.control.cancelled = true; item.control.wakeRecovery?.() })
|
|
2043
2169
|
}
|
|
2044
2170
|
await Promise.allSettled([...activeReplyRunners, ...activeWorkRunners].map((runner) => Promise.resolve().then(() => runner.cancelCurrent?.())))
|
|
2045
2171
|
await Promise.allSettled([...mentionIntake.values(), ...Object.values(queues).map((queue) => queue.settled()), ...messageDeliveries.values(), ...pendingOperations])
|
|
2046
2172
|
workRunners.close()
|
|
2047
2173
|
replyRunners.close()
|
|
2174
|
+
await activity.close()
|
|
2048
2175
|
try { await contextService.close() } catch (error) { log('context search shutdown failed: ' + (error?.message || error)) }
|
|
2049
2176
|
try { await memory.settled?.() }
|
|
2050
2177
|
catch (error) { log('memory shutdown failed: ' + (error?.message || error)) }
|
package/src/ws.mjs
CHANGED
|
@@ -37,7 +37,7 @@ export function assertWebSocket(fail) {
|
|
|
37
37
|
* @param {(kind: string, payload: any) => void} o.onEvent targeted dispatch.
|
|
38
38
|
* @param {(msg: string) => void} o.log
|
|
39
39
|
*/
|
|
40
|
-
export function connectAgentWs({ wsUrl, apiKey, identifier, onEvent, onConnect, log }) {
|
|
40
|
+
export function connectAgentWs({ wsUrl, apiKey, identifier, onEvent, onConnect, onDisconnect, log }) {
|
|
41
41
|
const base = stripSlash(wsUrl)
|
|
42
42
|
const url = `${base}?api_key=${encodeURIComponent(apiKey)}&identifier=${encodeURIComponent(identifier)}`
|
|
43
43
|
|
|
@@ -96,10 +96,11 @@ export function connectAgentWs({ wsUrl, apiKey, identifier, onEvent, onConnect,
|
|
|
96
96
|
})
|
|
97
97
|
|
|
98
98
|
const down = (why) => {
|
|
99
|
-
if (sock !== ws) return
|
|
99
|
+
if (closed || sock !== ws) return
|
|
100
100
|
clearKeepalive()
|
|
101
101
|
ws = null
|
|
102
102
|
log(why)
|
|
103
|
+
try { onDisconnect?.() } catch { /* connection telemetry is optional */ }
|
|
103
104
|
scheduleReconnect()
|
|
104
105
|
}
|
|
105
106
|
// $connect 401 (bad/inactive credentials) surfaces as an immediate close.
|
package/studio/app.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// The studio displays recorded, visible actions. It never starts work or infers a plan.
|
|
2
|
+
import { botAvatarUrl } from './bot-avatar.mjs';
|
|
2
3
|
const MAX_EVENTS = 1000;
|
|
3
4
|
const PAGE_SIZE = 40;
|
|
4
5
|
const terminalStatuses = new Set(['completed', 'failed', 'cancelled', 'offline', 'skipped', 'blocked', 'timeout', 'continued']);
|
|
@@ -82,7 +83,7 @@ export function deriveModel(input) {
|
|
|
82
83
|
if (event.type.startsWith('cycle.')) {
|
|
83
84
|
if (data.kind != null) cycle.data.kind = data.kind;
|
|
84
85
|
cycle.status = eventStatus(event);
|
|
85
|
-
if (event.type === 'cycle.started') cycle.startedAt
|
|
86
|
+
if (event.type === 'cycle.started') cycle.startedAt ||= event.timestamp;
|
|
86
87
|
if (terminalStatuses.has(cycle.status)) cycle.finishedAt = event.timestamp;
|
|
87
88
|
}
|
|
88
89
|
if (event.type.startsWith('tool.')) {
|
|
@@ -287,6 +288,14 @@ function initializeStudio() {
|
|
|
287
288
|
const element = node('span', `badge ${status}`, label);
|
|
288
289
|
return element;
|
|
289
290
|
}
|
|
291
|
+
function agentImage(agent, size = 31) {
|
|
292
|
+
const profile = state.model.agents.find(item => agentId(item) === agentId(agent)) || agent;
|
|
293
|
+
const image = node('img', 'bot-avatar');
|
|
294
|
+
image.src = botAvatarUrl(profile.avatarSeed || profile.slug || profile.identifier || profile.id);
|
|
295
|
+
image.alt = ''; image.setAttribute('aria-hidden', 'true');
|
|
296
|
+
image.width = size; image.height = size;
|
|
297
|
+
return image;
|
|
298
|
+
}
|
|
290
299
|
function toast(message) {
|
|
291
300
|
clearTimeout(toastTimer);
|
|
292
301
|
$('toast').textContent = message;
|
|
@@ -352,10 +361,12 @@ function initializeStudio() {
|
|
|
352
361
|
button.setAttribute('aria-pressed', String(state.selectedAgent === id));
|
|
353
362
|
const avatar = node('span', 'agent-avatar');
|
|
354
363
|
if (id === 'all') avatar.append(icon('agents'));
|
|
355
|
-
else { avatar.
|
|
364
|
+
else { avatar.classList.add('has-image'); avatar.append(agentImage(agent), node('span', `avatar-status ${agent.status}`)); }
|
|
356
365
|
const body = node('span', 'agent-text');
|
|
357
366
|
body.append(node('span', 'agent-name', name), node('span', 'agent-caption', caption));
|
|
358
|
-
|
|
367
|
+
const selection = node('span', 'agent-selection'); selection.append(icon('check'));
|
|
368
|
+
selection.setAttribute('aria-hidden', 'true');
|
|
369
|
+
button.append(avatar, body, selection);
|
|
359
370
|
button.addEventListener('click', () => { state.selectedAgent = id; resetSelection(); scheduleRender(); });
|
|
360
371
|
return button;
|
|
361
372
|
}
|
|
@@ -704,9 +715,11 @@ function initializeStudio() {
|
|
|
704
715
|
branch.setAttribute('aria-pressed', String(item.id === cycle.id));
|
|
705
716
|
const marker = node('span', `fanout-marker ${item.status}`);
|
|
706
717
|
marker.append(icon(item.status === 'completed' ? 'check' : ['failed', 'cancelled', 'blocked', 'timeout'].includes(item.status) ? 'close' : ['active', 'recovering'].includes(item.status) ? 'loader' : 'clock'));
|
|
707
|
-
|
|
718
|
+
const identity = node('span', 'fanout-identity');
|
|
719
|
+
identity.append(agentImage(item.agent, 20), node('span', 'fanout-name', agentName(item.agent)));
|
|
720
|
+
branch.append(marker, identity);
|
|
708
721
|
branch.addEventListener('click', () => {
|
|
709
|
-
state.selectedCycle = item.id; state.selectedEvent = null; state.selectedAgent =
|
|
722
|
+
state.selectedCycle = item.id; state.selectedEvent = null; state.selectedAgent = agentId(item.agent);
|
|
710
723
|
state.view = 'cycles'; state.search = ''; state.kind = 'all'; state.status = 'all';
|
|
711
724
|
$('event-search').value = ''; $('kind-filter').value = 'all'; $('status-filter').value = 'all';
|
|
712
725
|
state.follow = false; $('follow-latest').checked = false; scheduleRender();
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Embedded version of frontend/components/team/BotAvatar.tsx. Keep the seed,
|
|
2
|
+
// palettes and SVG geometry identical so an agent looks the same in both apps.
|
|
3
|
+
// Only generated numbers and fixed shapes enter the SVG; no remote images.
|
|
4
|
+
function hashSeed(seed) {
|
|
5
|
+
let h = 2166136261 >>> 0;
|
|
6
|
+
for (let i = 0; i < seed.length; i++) { h ^= seed.charCodeAt(i); h = Math.imul(h, 16777619); }
|
|
7
|
+
return h >>> 0;
|
|
8
|
+
}
|
|
9
|
+
function mulberry32(seed) {
|
|
10
|
+
let a = seed >>> 0;
|
|
11
|
+
return () => {
|
|
12
|
+
a = (a + 0x6d2b79f5) | 0;
|
|
13
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
14
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
15
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
const catalogHues = { sentinel: 0, forge: 40, pulse: 80, nova: 120, sage: 160, atlas: 200, vera: 240, relay: 280, quill: 320 };
|
|
19
|
+
function botSpec(seed) {
|
|
20
|
+
const h = Object.hasOwn(catalogHues, seed) ? catalogHues[seed] : hashSeed(seed + '#hue') % 360;
|
|
21
|
+
const v = hashSeed(seed + '#tone') % 97, saturation = 58 + v % 20, lightness = 50 + v % 9;
|
|
22
|
+
const rng = mulberry32(hashSeed(seed));
|
|
23
|
+
const pick = choices => choices[Math.floor(rng() * choices.length)];
|
|
24
|
+
return {
|
|
25
|
+
p: {
|
|
26
|
+
tile: `hsl(${h} ${Math.min(saturation + 6, 84)}% 90%)`,
|
|
27
|
+
body: `hsl(${h} ${saturation}% ${lightness}%)`,
|
|
28
|
+
dark: `hsl(${h} ${Math.min(saturation + 4, 82)}% ${lightness - 17}%)`,
|
|
29
|
+
face: `hsl(${h} 48% 97%)`,
|
|
30
|
+
accent: `hsl(${(h + 150 + v % 61) % 360} 82% 56%)`,
|
|
31
|
+
},
|
|
32
|
+
headRx: pick([14, 20, 26, 26]),
|
|
33
|
+
antenna: pick(['none', 'single', 'double', 'dish']),
|
|
34
|
+
eyes: pick(['round', 'square', 'visor', 'cyclops', 'happy']),
|
|
35
|
+
mouth: pick(['grid', 'smile', 'flat', 'dots', 'wave']),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function botAvatarSvg(seed) {
|
|
40
|
+
const spec = botSpec(String(seed || 'bot')), { p } = spec;
|
|
41
|
+
const element = (tag, attributes, children = '') => `<${tag} ${Object.entries(attributes).map(([key, value]) => `${key}="${value}"`).join(' ')}>${children}</${tag}>`;
|
|
42
|
+
const circle = (cx, cy, r, fill, more = {}) => element('circle', { cx, cy, r, fill, ...more });
|
|
43
|
+
const rect = (x, y, width, height, rx, fill) => element('rect', { x, y, width, height, rx, fill });
|
|
44
|
+
const line = (x1, y1, x2, y2, more = {}) => element('line', { x1, y1, x2, y2, ...more });
|
|
45
|
+
const stroke = (color, width) => ({ stroke: color, 'stroke-width': width, 'stroke-linecap': 'round' });
|
|
46
|
+
const antenna = {
|
|
47
|
+
single: element('g', stroke(p.dark, 3), line(50, 26, 50, 15) + circle(50, 12, 4, p.accent, { stroke: 'none' })),
|
|
48
|
+
double: element('g', stroke(p.dark, 2.6), line(40, 26, 37, 16) + line(60, 26, 63, 16) + circle(36, 14, 3.2, p.accent, { stroke: 'none' }) + circle(64, 14, 3.2, p.accent, { stroke: 'none' })),
|
|
49
|
+
dish: element('g', {}, line(50, 26, 50, 16, stroke(p.dark, 3)) + element('ellipse', { cx: 50, cy: 14, rx: 10, ry: 3.4, fill: p.dark }) + circle(50, 12, 2.4, p.accent)),
|
|
50
|
+
}[spec.antenna] || '';
|
|
51
|
+
const eyes = {
|
|
52
|
+
round: circle(42, 48, 5, p.dark) + circle(58, 48, 5, p.dark) + circle(43.4, 46.6, 1.6, '#fff') + circle(59.4, 46.6, 1.6, '#fff'),
|
|
53
|
+
square: rect(37, 43, 9, 9, 2.5, p.dark) + rect(54, 43, 9, 9, 2.5, p.dark),
|
|
54
|
+
visor: element('g', {}, rect(36, 44, 28, 9, 4.5, p.dark) + circle(44, 48.5, 2.4, p.accent) + circle(56, 48.5, 2.4, p.accent)),
|
|
55
|
+
cyclops: circle(50, 48, 8, p.dark) + circle(50, 48, 3.4, p.accent),
|
|
56
|
+
happy: element('g', { ...stroke(p.dark, 3), fill: 'none' }, element('path', { d: 'M37 49 q5 -6 10 0' }) + element('path', { d: 'M53 49 q5 -6 10 0' })),
|
|
57
|
+
}[spec.eyes];
|
|
58
|
+
const mouth = {
|
|
59
|
+
grid: element('g', {}, rect(42, 58, 16, 6.5, 2, p.dark) + [47, 50, 53].map(x => line(x, 58, x, 64.5, { stroke: p.face, 'stroke-width': 1.2 })).join('')),
|
|
60
|
+
smile: element('path', { d: 'M42 59 q8 7 16 0', ...stroke(p.dark, 3), fill: 'none' }),
|
|
61
|
+
flat: rect(43, 60.5, 14, 3, 1.5, p.dark),
|
|
62
|
+
dots: element('g', { fill: p.dark }, [44, 50, 56].map(cx => element('circle', { cx, cy: 61, r: 1.8 })).join('')),
|
|
63
|
+
wave: element('path', { d: 'M42 61 q3 -4 6 0 t6 0', ...stroke(p.dark, 2.6), fill: 'none' }),
|
|
64
|
+
}[spec.mouth];
|
|
65
|
+
return element('svg', { xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 100 100' }, rect(0, 0, 100, 100, 24, p.tile) + antenna + rect(18, 46, 5, 16, 2.5, p.dark) + rect(77, 46, 5, 16, 2.5, p.dark) + rect(24, 26, 52, 50, spec.headRx, p.body) + rect(31, 35, 38, 32, 9, p.face) + eyes + mouth);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function botAvatarUrl(seed) {
|
|
69
|
+
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(botAvatarSvg(seed));
|
|
70
|
+
}
|
package/studio/style.css
CHANGED
|
@@ -45,6 +45,8 @@
|
|
|
45
45
|
button:disabled { cursor: default; opacity: .6; }
|
|
46
46
|
button, a, input, select, summary { touch-action: manipulation; }
|
|
47
47
|
:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; }
|
|
48
|
+
input:focus, select:focus, textarea:focus { outline: none; }
|
|
49
|
+
input:focus-visible, select:focus-visible, textarea:focus-visible { box-shadow: inset 0 -2px 0 var(--focus); }
|
|
48
50
|
button, select, a { transition: background-color .16s ease-out, color .16s ease-out, box-shadow .16s ease-out; }
|
|
49
51
|
h1, h2, h3, h4, p { margin: 0; }
|
|
50
52
|
h1, h2, h3, h4, strong { font-weight: 500; }
|
|
@@ -76,12 +78,18 @@
|
|
|
76
78
|
.count { font-size: 12px; background: var(--stage); min-width: 22px; height: 19px; display: grid; place-items: center; border-radius: 999px; font-variant-numeric: tabular-nums; }
|
|
77
79
|
.agent-search { display: flex; align-items: center; gap: 7px; padding: 8px 10px; margin: 0 0 12px; color: var(--subtle); }
|
|
78
80
|
.agent-search .icon { width: 14px; height: 14px; }
|
|
79
|
-
.agent-search input { width: 100%; border: 0;
|
|
81
|
+
.agent-search input { width: 100%; border: 0; background: transparent; font-size: 12px; }
|
|
80
82
|
.agent-list { display: flex; flex-direction: column; gap: 5px; }
|
|
81
83
|
.agent-item { display: flex; align-items: center; gap: 10px; width: 100%; text-align: left; border: 0; background: transparent; padding: 10px; border-radius: var(--radius-md); corner-shape: squircle; }
|
|
82
84
|
.agent-item:hover { background: var(--hover); }
|
|
83
|
-
.agent-item.is-selected { background: var(--
|
|
85
|
+
.agent-item.is-selected { background: color-mix(in oklab, var(--ink) 8%, transparent); box-shadow: inset 0 0 0 1px var(--border); }
|
|
86
|
+
.agent-selection { display: grid; place-items: center; width: 15px; flex-shrink: 0; visibility: hidden; }
|
|
87
|
+
.agent-selection .icon { width: 14px; height: 14px; }
|
|
88
|
+
.agent-item.is-selected .agent-selection { visibility: visible; }
|
|
84
89
|
.agent-avatar { width: 31px; height: 31px; display: grid; place-items: center; position: relative; flex-shrink: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; corner-shape: squircle; color: var(--muted); font-size: 12px; font-weight: 500; }
|
|
90
|
+
.agent-avatar.has-image { border: 0; background: transparent; }
|
|
91
|
+
.bot-avatar { display: block; flex-shrink: 0; }
|
|
92
|
+
.fanout-identity { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 0; max-width: 100%; }
|
|
85
93
|
.avatar-status { position: absolute; width: 7px; height: 7px; right: -1px; bottom: -1px; border-radius: 50%; border: 2px solid var(--background); box-sizing: content-box; background: var(--subtle); }
|
|
86
94
|
.avatar-status.online { background: var(--success); }
|
|
87
95
|
.agent-text { flex: 1; min-width: 0; }
|
|
@@ -123,7 +131,7 @@
|
|
|
123
131
|
.filters { display: flex; gap: 9px; margin-bottom: 19px; }
|
|
124
132
|
.search-field { flex: 1; min-width: 90px; display: flex; align-items: center; gap: 9px; border: 1px solid var(--border); border-radius: 999px; padding: 8px 12px; color: var(--subtle); }
|
|
125
133
|
.search-field .icon { width: 14px; height: 14px; }
|
|
126
|
-
.search-field input { width: 100%; background: transparent; border: 0; font-size: 12px;
|
|
134
|
+
.search-field input { width: 100%; background: transparent; border: 0; font-size: 12px; }
|
|
127
135
|
.select-field { display: inline-flex; align-items: center; min-width: 108px; }
|
|
128
136
|
.select-field select { width: 100%; min-height: 40px; padding: 7px 26px 7px 12px; border: 1px solid var(--border); border-radius: 999px; background-color: var(--surface); font-size: 12px; }
|
|
129
137
|
.content-grid { display: grid; grid-template-columns: minmax(280px, .85fr) minmax(360px, 1.15fr); gap: 14px; align-items: start; }
|
|
@@ -360,7 +368,6 @@
|
|
|
360
368
|
.model-settings-form label { display: grid; gap: 4px; font-size: 13px; margin-top: 8px; }
|
|
361
369
|
.model-settings-form label span { font-size: 12px; color: var(--muted); }
|
|
362
370
|
.model-settings-form input { width: 100%; min-width: 0; padding: 11px 14px; border: 1px solid var(--border); border-radius: 18px; background: transparent; color: var(--ink); font-size: 13px; }
|
|
363
|
-
.model-settings-form input:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
|
364
371
|
.model-settings-actions { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px; margin-top: 8px; }
|
|
365
372
|
#model-settings-error { color: #9f3030; }
|
|
366
373
|
@media (max-width: 640px) {
|