openvisio-agent 0.25.9 → 0.25.11
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 +12 -1
- package/README.md +1 -1
- package/package.json +1 -1
- package/scripts/certify.mjs +9 -2
- package/src/activity-reporter.mjs +13 -5
- package/src/codex-mcp-proxy.mjs +6 -4
- package/src/events.mjs +23 -4
- package/src/tool-policy.mjs +100 -0
- package/src/watch.mjs +67 -19
- package/src/ws.mjs +36 -6
- package/studio/style.css +13 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## [0.25.11] — 2026-09-21
|
|
4
|
+
|
|
5
|
+
- Explain and enforce task ownership identity: `assignee_id` is for human members, while `agent_id` is for registered agents, and task mutations cannot send both.
|
|
6
|
+
- Keep agent messages, task comments, titles, and descriptions within readable limits at the shared MCP bridge; watcher-owned oversized replies are shortened while full output remains in Agent Studio.
|
|
7
|
+
- Stop automated peer exchanges after one agent reply until a human speaks, suppress sender-less echoes of watcher comments, and ignore explicitly self-authored task status events before they start another cycle.
|
|
8
|
+
|
|
9
|
+
## [0.25.10] — 2026-09-21
|
|
10
|
+
|
|
11
|
+
- Send `thinking`, `working`, and `typing` activity through the authenticated agent WebSocket, with the REST endpoint retained only while the socket is reconnecting.
|
|
12
|
+
- Accept documented and compatible WebSocket event envelopes on both the watcher and frontend, including flat bare-agent `agent_status` frames, and normalize them before routing.
|
|
13
|
+
- Keep active cycles running through ordinary peer-agent chatter; only an analyzed redirect or explicit stand-down cancels work in that thread.
|
|
14
|
+
- Compact Agent Studio activity and cycle rows into single-line summaries so more history remains visible at once.
|
|
4
15
|
|
|
5
16
|
## [0.25.9] — 2026-09-21
|
|
6
17
|
|
package/README.md
CHANGED
|
@@ -87,7 +87,7 @@ The backend MCP may be stateful or stateless. A successful initialize response w
|
|
|
87
87
|
|
|
88
88
|
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.
|
|
89
89
|
|
|
90
|
-
Both `agent:mention` and organization `channel:message` events pass through the same source-recipient, ownership, and deduplication checks before any model starts. The watcher hydrates compact roster entries through `get_agent`, subscribes only to the organization verified from that authenticated profile, and restores the subscription after reconnect. Organization `task:created` and `task:updated` events enter the same authoritative assignment verifier as targeted `task:assigned` events, so a task starts only after `get_ticket` confirms it is open and owned by this agent.
|
|
90
|
+
Both `agent:mention` and organization `channel:message` events pass through the same source-recipient, ownership, and deduplication checks before any model starts. The watcher hydrates compact roster entries through `get_agent`, subscribes only to the organization verified from that authenticated profile, and restores the subscription after reconnect. Organization `task:created` and `task:updated` events enter the same authoritative assignment verifier as targeted `task:assigned` events, so a task starts only after `get_ticket` confirms it is open and owned by this agent. Explicit self-authored task updates and 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 untagged human follow-ups remain addressed to the agent. Another BYO agent can make one concrete request after the latest human turn; after this agent answers, further peer turns stay silent until a human speaks. Routine agent chatter, reciprocal questions, and sender-less echoes of watcher-delivered task comments remain silent. Ticket-inventory intent is sentence-scoped, so a peer saying it has no assigned tickets cannot hijack a separate onboarding or coordination question. Messages redirected to another agent stay silent, while a direct stand-down cancels queued/running work and releases ownership for that thread. Agent messages and comments are kept within 1,200 characters, and task mutations expose the distinction between human `assignee_id` and agent `agent_id`. 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 to the source thread or default agent channel. Every runtime returns a distinct final answer for guarded watcher delivery. Plans and reconnects cannot duplicate or suppress that final answer.
|
|
91
91
|
|
|
92
92
|
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.
|
|
93
93
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openvisio-agent",
|
|
3
|
-
"version": "0.25.
|
|
3
|
+
"version": "0.25.11",
|
|
4
4
|
"description": "Connect Claude Code, Codex, OpenCode, Gemini CLI, or Qwen Code to an OpenVisio team \u2014 MCP tools + optional autonomy \u2014 in one command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/scripts/certify.mjs
CHANGED
|
@@ -18,6 +18,7 @@ for (const directory of ['src', 'bin', 'studio']) {
|
|
|
18
18
|
}
|
|
19
19
|
run('unit and behavior tests', process.execPath, ['--test'])
|
|
20
20
|
run('frontend typecheck', 'npm', ['run', 'typecheck'], join(repo, 'frontend'))
|
|
21
|
+
run('frontend realtime frame tests', process.execPath, ['--test', 'lib/api/backend/realtimeFrame.test.mjs'], join(repo, 'frontend'))
|
|
21
22
|
run('package dry run', 'npm', ['pack', '--dry-run'])
|
|
22
23
|
run('diff whitespace check', 'git', ['diff', '--check'], repo)
|
|
23
24
|
|
|
@@ -29,9 +30,11 @@ const mcpHttp = readFileSync(join(root, 'src', 'mcp-http.mjs'), 'utf8')
|
|
|
29
30
|
const opencodeConfig = readFileSync(join(root, 'src', 'opencode-config.mjs'), 'utf8')
|
|
30
31
|
const codexConfig = readFileSync(join(root, 'src', 'codex-config.mjs'), 'utf8')
|
|
31
32
|
const codexProxy = readFileSync(join(root, 'src', 'codex-mcp-proxy.mjs'), 'utf8')
|
|
33
|
+
const toolPolicy = readFileSync(join(root, 'src', 'tool-policy.mjs'), 'utf8')
|
|
32
34
|
const prPush = readFileSync(join(root, 'src', 'pr-push.mjs'), 'utf8')
|
|
33
35
|
const cli = readFileSync(join(root, 'bin', 'cli.mjs'), 'utf8')
|
|
34
36
|
const websocket = readFileSync(join(root, 'src', 'ws.mjs'), 'utf8')
|
|
37
|
+
const activityReporter = readFileSync(join(root, 'src', 'activity-reporter.mjs'), 'utf8')
|
|
35
38
|
const cycleQueue = readFileSync(join(root, 'src', 'cycle-queue.mjs'), 'utf8')
|
|
36
39
|
const mastraHarness = readFileSync(join(root, 'src', 'mastra-harness.mjs'), 'utf8')
|
|
37
40
|
const runtimeProviders = readFileSync(join(root, 'src', 'runtime-providers.mjs'), 'utf8')
|
|
@@ -57,6 +60,9 @@ const assertions = [
|
|
|
57
60
|
['connection recovery scans assignments without an activity-feed pass', watcher.includes('const reconcileBacklog') && watcher.includes("await handleTaskSignal('task:assigned'") && !watcher.includes('reconcileActivity')],
|
|
58
61
|
['task:assigned is handled', watcher.includes("k === 'task:assigned'")],
|
|
59
62
|
['task creation and update fanout use the assignment verifier', watcher.includes("k === 'task:created'") && watcher.includes("k === 'task:updated'") && watcher.includes('return handleTaskSignal(k, raw)')],
|
|
63
|
+
['task ownership distinguishes human assignees from registered agents', toolPolicy.includes('assignee_id is only for a human user/member id') && toolPolicy.includes('agent_id is only for a registered coding agent id') && codexProxy.includes('validateAgentToolArguments')],
|
|
64
|
+
['agent-authored task updates stop before ticket verification', watcher.includes("kind === 'task:updated'") && watcher.includes('taskEventSenderIsSelf(raw') && events.includes('export function taskEventSenderIsSelf')],
|
|
65
|
+
['agent communication has shared readable limits', toolPolicy.includes('post_message:') && toolPolicy.includes('max: 1200') && toolPolicy.includes('compactAgentMessage') && watcher.includes('compactAgentMessage(content, { maxChars: 1200 })')],
|
|
60
66
|
['compact agent records are hydrated before organization subscription', watcher.includes("callMcpTool('get_agent', { agent_id: selfAgentId })") && watcher.includes('selfAgentId != null && selfOrganizationId == null')],
|
|
61
67
|
['task signals are verified with get_ticket', watcher.includes("callMcpTool('get_ticket'")],
|
|
62
68
|
['review and testing handoffs do not restart work', watcher.includes('taskIsAwaitingReview(task, reviewIds)') && watcher.includes('taskIsAwaitingReview(ticket, typeSets.review)')],
|
|
@@ -65,13 +71,14 @@ const assertions = [
|
|
|
65
71
|
['agent results are delivered independently of ticket completion', watcher.includes('agentFinal: true') && watcher.includes('pendingAgentResults')],
|
|
66
72
|
['completion delivery survives reconnect and deduplicates', watcher.includes('pendingCompletionReports: [...pendingCompletionReports]') && watcher.includes('reportedCompletions: [...reportedCompletions]') && watcher.includes('reportedCompletions.has(report.key)')],
|
|
67
73
|
['optional ticket comments cannot block verified completion', watcher.includes('callOptionalTicketComment({ projectId, ticketId, content: report.content })') && watcher.includes('no ticket comment tool is exposed; completing ticket') && watcher.includes('reportedTaskComments: [...reportedTaskComments]') && watcher.includes('reportedTaskComments.has(report.key)')],
|
|
68
|
-
['ticket comments prefer the advertised modern schema with legacy compatibility', watcher.includes("names.has('create_task_comment') ? 'create_task_comment' : names.has('comment_ticket') ? 'comment_ticket' : ''") && watcher.includes("name === 'create_task_comment'") && watcher.includes("{ parent_id: Number(parentId) }") && watcher.includes("{ text:
|
|
74
|
+
['ticket comments prefer the advertised modern schema with legacy compatibility', watcher.includes("names.has('create_task_comment') ? 'create_task_comment' : names.has('comment_ticket') ? 'comment_ticket' : ''") && watcher.includes("name === 'create_task_comment'") && watcher.includes("{ parent_id: Number(parentId) }") && watcher.includes("{ content: message") && watcher.includes("{ text: message }")],
|
|
69
75
|
['uncertain comment writes never trigger duplicate alternate ticket writes', watcher.includes('never try another comment endpoint after an error') && watcher.includes('no alternate ticket write will be attempted') && !watcher.includes("reason: 'not-available'")],
|
|
70
76
|
['ticket comments cannot masquerade as channel completion', events.includes("didChannelMessage: completed && mcpTool === 'post_message'") && watcher.includes('result.didChannelMessage')],
|
|
71
77
|
['single-watcher acquisition is atomic and fails closed', watcher.includes("openSync(lockPath, 'wx')") && watcher.includes('Could not acquire the single-watcher lock')],
|
|
72
78
|
['websocket mention delivery uses a persistent replay guard', watcher.includes('markMentionHandled(msg, cid)') && watcher.includes('recentMentionSignatures')],
|
|
73
79
|
['conversation wake events preserve owned-thread follow-ups and filter other recipients before model start', watcher.includes("memory.has(controlKey, 'active')") && watcher.includes('classifyConversationTarget(msg, [...selfAliases], { threadOwned })') && events.includes("'owned-thread-follow-up'") && events.includes("reason: 'explicit-other-recipient'") && events.includes("reason: 'other-agent-chatter'")],
|
|
74
80
|
['owned threads admit concrete peer requests without opening agent chatter loops', events.includes("reason: peerFollowUp ? 'owned-peer-follow-up'") && watcher.includes('This request was authored by another team agent') && watcher.includes('do not append a reciprocal question')],
|
|
81
|
+
['peer-agent exchanges stop after one reply until a human turn', watcher.includes("state: 'replied'") && watcher.includes("state: 'human'") && watcher.includes('peer-loop-budget') && watcher.includes('ignored-peer-loop')],
|
|
75
82
|
['pending-ticket intent is scoped to one clause', events.includes("text.split(/(?<=[.!?])") && events.includes('return tasks && pending && question')],
|
|
76
83
|
['prematurely acknowledged owned-thread replies recover exactly once', watcher.includes("!threadOwned || memory.has(sourceKey, 'received')") && watcher.includes('agent:mention replay recovered for active owned thread')],
|
|
77
84
|
['task comments use assigned-ticket verification and watcher-owned threaded delivery', watcher.includes("k === 'task:comment'") && watcher.includes('taskCommentFromEvent(raw)') && watcher.includes('postTaskCommentOnce') && watcher.includes("surface: 'task-comment'") && watcher.includes("['post_message', 'create_task_comment', 'comment_ticket']")],
|
|
@@ -100,7 +107,7 @@ const assertions = [
|
|
|
100
107
|
['work and reply cancellation targets are isolated', watcher.includes('item.control.cancelled = true') && watcher.includes('item.control.runner?.cancelCurrent')],
|
|
101
108
|
['fresh threads resolve the dedicated agent channel', watcher.includes('dedicatedChannel(Array.isArray(channels)') && watcher.includes('No dedicated agent channel available for a new thread')],
|
|
102
109
|
['presence follows runtime activity and stops refreshing with its cycle', watcher.includes('createActivityReporter') && watcher.includes("activity.set(data.cycleId, live.control.statusTargets, 'working')") && watcher.includes('activity.clear(cycleControl.cycleId)')],
|
|
103
|
-
['websocket
|
|
110
|
+
['agent activity prefers websocket with REST reconnect fallback', activityReporter.includes("type: 'agent_status'") && activityReporter.includes('sendFrame?.') && activityReporter.includes('agentStateRequest') && websocket.includes('return true')],
|
|
104
111
|
['frontend consumes thinking event', activityHook.includes("'channel:agent:thinking'")],
|
|
105
112
|
['frontend consumes working event', activityHook.includes("'channel:agent:working'")],
|
|
106
113
|
['frontend consumes typing event', activityHook.includes("'channel:agent:typing'")],
|
|
@@ -2,11 +2,11 @@ import { agentStateRequest } from './events.mjs'
|
|
|
2
2
|
|
|
3
3
|
// Presence is ephemeral, independent of task completion and message delivery.
|
|
4
4
|
// The backend supports no "done" event; stopping refresh lets the UI TTL clear it.
|
|
5
|
-
export function createActivityReporter({ backend, apiKey, identifier, fetchImpl = fetch, now = Date.now, log = () => {}, intervalMs = 3_000 }) {
|
|
5
|
+
export function createActivityReporter({ backend, apiKey, identifier, sendFrame, fetchImpl = fetch, now = Date.now, log = () => {}, intervalMs = 3_000 }) {
|
|
6
6
|
const cycles = new Map(), channels = new Map(), pending = new Set()
|
|
7
7
|
let closed = false
|
|
8
8
|
const pulse = () => {
|
|
9
|
-
if (closed || !backend) return
|
|
9
|
+
if (closed || (!backend && !sendFrame)) return
|
|
10
10
|
const states = new Map()
|
|
11
11
|
const priority = { thinking: 0, working: 1, typing: 2 }
|
|
12
12
|
for (const cycle of cycles.values()) for (const channelId of cycle.channels) {
|
|
@@ -15,11 +15,19 @@ export function createActivityReporter({ backend, apiKey, identifier, fetchImpl
|
|
|
15
15
|
for (const [channelId, state] of states) {
|
|
16
16
|
const prior = channels.get(channelId)
|
|
17
17
|
if (prior?.busy || now() < (prior?.retryAt || 0) || (prior?.state === state && now() - prior.at < (state === 'working' ? 10_000 : 3_000))) continue
|
|
18
|
+
const entry = { state, at: now(), busy: false, controller: null }
|
|
19
|
+
channels.set(channelId, entry)
|
|
20
|
+
// The authenticated agent socket is the live event path. Older backend
|
|
21
|
+
// stages and reconnect gaps retain the REST endpoint as a fallback.
|
|
22
|
+
try {
|
|
23
|
+
if (sendFrame?.({ type: 'agent_status', channel_id: channelId, state }) === true) continue
|
|
24
|
+
} catch { /* fall through to REST */ }
|
|
25
|
+
if (!backend) continue
|
|
18
26
|
const controller = new AbortController()
|
|
19
27
|
const timer = setTimeout(() => controller.abort(), 5_000)
|
|
20
28
|
timer.unref?.()
|
|
21
|
-
|
|
22
|
-
|
|
29
|
+
entry.busy = true
|
|
30
|
+
entry.controller = controller
|
|
23
31
|
const request = agentStateRequest(backend, channelId, state, apiKey, identifier)
|
|
24
32
|
const operation = Promise.resolve().then(() => fetchImpl(request.url, { ...request.init, signal: controller.signal })).then(async response => {
|
|
25
33
|
await response.body?.cancel?.()
|
|
@@ -42,6 +50,6 @@ export function createActivityReporter({ backend, apiKey, identifier, fetchImpl
|
|
|
42
50
|
clear(cycleId) { cycles.delete(cycleId); pulse() },
|
|
43
51
|
pulse,
|
|
44
52
|
async settled() { await Promise.allSettled([...pending]) },
|
|
45
|
-
async close() { closed = true; clearInterval(timer); cycles.clear(); for (const entry of channels.values()) entry.controller
|
|
53
|
+
async close() { closed = true; clearInterval(timer); cycles.clear(); for (const entry of channels.values()) entry.controller?.abort(); await Promise.allSettled([...pending]) },
|
|
46
54
|
}
|
|
47
55
|
}
|
package/src/codex-mcp-proxy.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
4
4
|
import { WORK_SESSION_TOOL, runtimeControlTools, requestWorkSession } from './runtime-control.mjs'
|
|
5
5
|
import { createMcpHttpClient } from './mcp-http.mjs'
|
|
6
6
|
import { contextTools, contextToolNames, callContextService, validateContextArguments } from './context-tools.mjs'
|
|
7
|
+
import { validateAgentToolArguments, withAgentToolPolicy } from './tool-policy.mjs'
|
|
7
8
|
|
|
8
9
|
export function toolWithoutCredentialInputs(tool) {
|
|
9
10
|
const copy = structuredClone(tool)
|
|
@@ -17,7 +18,7 @@ export function toolWithoutCredentialInputs(tool) {
|
|
|
17
18
|
schema.required = schema.required.filter((name) => name !== 'agent_api_key' && name !== 'agent_identifier')
|
|
18
19
|
}
|
|
19
20
|
}
|
|
20
|
-
return copy
|
|
21
|
+
return withAgentToolPolicy(copy)
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
export function toolAllowed(name, { disabled = new Set(), allowed = null } = {}) {
|
|
@@ -146,16 +147,17 @@ export function runCodexMcpProxy() {
|
|
|
146
147
|
}
|
|
147
148
|
reply(id, { content: [{ type: 'text', text: JSON.stringify(data) }] }); return
|
|
148
149
|
}
|
|
150
|
+
const toolArguments = validateAgentToolArguments(name, request.params?.arguments || {})
|
|
149
151
|
let result
|
|
150
|
-
try { result = await client.callTool(name,
|
|
152
|
+
try { result = await client.callTool(name, toolArguments) }
|
|
151
153
|
catch (error) {
|
|
152
154
|
// An allowlisted coordination mutation still must never be replayed
|
|
153
155
|
// after an ambiguous timeout. Retry only idempotent reads.
|
|
154
156
|
if (!(allowedTools instanceof Set) || !allowedTools.has(name) || !/^(?:list_|get_|codebase_tree$)/.test(name)) throw error
|
|
155
|
-
result = await client.callTool(name,
|
|
157
|
+
result = await client.callTool(name, toolArguments)
|
|
156
158
|
}
|
|
157
159
|
if (localContext.length && ['list_message_thread', 'post_message'].includes(name) && !result?.isError) {
|
|
158
|
-
try { await callContextService(contextEnvironment, { action: 'observe', name, args:
|
|
160
|
+
try { await callContextService(contextEnvironment, { action: 'observe', name, args: toolArguments, result }, 1000) }
|
|
159
161
|
catch { /* optional indexing cannot fail the successful backend action */ }
|
|
160
162
|
}
|
|
161
163
|
reply(id, /^(?:list_agents|get_agent|get_agent_details)$/.test(name) ? agentResultWithoutCredentials(result) : result)
|
package/src/events.mjs
CHANGED
|
@@ -55,6 +55,25 @@ export function taskBelongsToAgent(task, { id, identifier } = {}) {
|
|
|
55
55
|
return !!identifier && assignedIdentifier != null && String(assignedIdentifier) === String(identifier)
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// A task's `agent_id` is its owner, not the author of a task update. Only
|
|
59
|
+
// explicit actor fields can prove that an incoming task event came from this
|
|
60
|
+
// agent. This distinction prevents an assigned agent from treating every task
|
|
61
|
+
// update as self-authored while still suppressing its own status mutations.
|
|
62
|
+
export function taskEventSenderIsSelf(payload, identity = {}) {
|
|
63
|
+
if (!payload || typeof payload !== 'object') return false
|
|
64
|
+
const envelopes = [payload, payload.data, payload.payload, payload.task, payload.data?.task, payload.payload?.task]
|
|
65
|
+
.filter((value) => value && typeof value === 'object' && !Array.isArray(value))
|
|
66
|
+
for (const source of envelopes) {
|
|
67
|
+
const actor = [source.actor_agent, source.actorAgent, source.updated_by_agent, source.updatedByAgent, source.changed_by_agent, source.changedByAgent, source.sender_agent, source.senderAgent, source.author_agent, source.authorAgent]
|
|
68
|
+
.find((value) => value && typeof value === 'object' && !Array.isArray(value))
|
|
69
|
+
const actorId = source.actor_agent_id ?? source.actorAgentId ?? source.updated_by_agent_id ?? source.updatedByAgentId ?? source.changed_by_agent_id ?? source.changedByAgentId ?? source.sender_agent_id ?? source.senderAgentId ?? source.author_agent_id ?? source.authorAgentId
|
|
70
|
+
const actorIdentifier = source.actor_agent_identifier ?? source.actorAgentIdentifier ?? source.updated_by_agent_identifier ?? source.updatedByAgentIdentifier ?? source.sender_agent_identifier ?? source.senderAgentIdentifier ?? source.author_agent_identifier ?? source.authorAgentIdentifier
|
|
71
|
+
if (!actor && actorId == null && actorIdentifier == null) continue
|
|
72
|
+
if (messageSenderIsSelf({ senderAgent: actor || { id: actorId, identifier: actorIdentifier }, sender_agent_id: actorId, sender_identifier: actorIdentifier }, identity)) return true
|
|
73
|
+
}
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
76
|
+
|
|
58
77
|
// Backend task ids are database plumbing. Teammates identify tickets by the
|
|
59
78
|
// project-scoped slug shown on the board (for example, OVS-57). Never synthesize
|
|
60
79
|
// a slug from the numeric id: if an older backend omits it, human-facing copy
|
|
@@ -415,12 +434,12 @@ export function normalizeRenderedMessageText(value) {
|
|
|
415
434
|
export function messageSenderIsSelf(row, identity = {}) {
|
|
416
435
|
if (!row || typeof row !== 'object') return false
|
|
417
436
|
const sender = [row.taskSender, row.task_sender, row.sender, row.user, row.author, row.member].find((item) => item && typeof item === 'object') || {}
|
|
418
|
-
const expanded = [row.taskSenderAgent, row.task_sender_agent, row.senderAgent, row.sender_agent, row.agent, sender.agent].find((item) => item && typeof item === 'object')
|
|
437
|
+
const expanded = [row.taskSenderAgent, row.task_sender_agent, row.senderAgent, row.sender_agent, row.authorAgent, row.author_agent, row.actorAgent, row.actor_agent, row.createdByAgent, row.created_by_agent, row.agent, sender.agent].find((item) => item && typeof item === 'object')
|
|
419
438
|
const senderKind = String(sender.type ?? sender.kind ?? sender.sender_type ?? row.sender_type ?? row.author_type ?? '').toLowerCase()
|
|
420
439
|
const senderLooksLikeAgent = agentSenderKind(senderKind) || (!humanSenderKind(senderKind) && (sender.agent_id != null || sender.identifier != null || (sender.slug != null && !sender.email)))
|
|
421
440
|
const agent = expanded || (senderLooksLikeAgent ? sender : {})
|
|
422
|
-
const agentId = agent.id ?? agent.agent_id ?? row.agent_id ?? row.sender_agent_id ?? row.senderAgentId
|
|
423
|
-
const identifier = String(agent.identifier ?? agent.slug ?? row.agent_identifier ?? row.sender_identifier ?? '')
|
|
441
|
+
const agentId = agent.id ?? agent.agent_id ?? row.agent_id ?? row.sender_agent_id ?? row.senderAgentId ?? row.task_sender_agent_id ?? row.taskSenderAgentId ?? row.author_agent_id ?? row.authorAgentId ?? row.actor_agent_id ?? row.actorAgentId ?? row.created_by_agent_id ?? row.createdByAgentId
|
|
442
|
+
const identifier = String(agent.identifier ?? agent.slug ?? row.agent_identifier ?? row.sender_identifier ?? row.task_sender_agent_identifier ?? row.taskSenderAgentIdentifier ?? row.author_agent_identifier ?? row.authorAgentIdentifier ?? row.actor_agent_identifier ?? row.actorAgentIdentifier ?? '')
|
|
424
443
|
const name = String(agent.name ?? agent.display_name ?? (senderLooksLikeAgent ? sender.name : '') ?? '')
|
|
425
444
|
const aliases = new Set([identity.identifier, identity.slug, identity.name].map((item) => String(item || '').toLowerCase()).filter(Boolean))
|
|
426
445
|
const stableAliases = new Set([identity.identifier, identity.slug].map((item) => String(item || '').toLowerCase()).filter(Boolean))
|
|
@@ -501,7 +520,7 @@ function conversationMentions(value, selfAliases) {
|
|
|
501
520
|
|
|
502
521
|
export function messageSenderIsAgent(message) {
|
|
503
522
|
const m = message && typeof message === 'object' ? message : {}
|
|
504
|
-
if (m.taskSenderAgent || m.task_sender_agent || m.senderAgent || m.sender_agent || m.agent || m.agent_id != null || m.sender_agent_id != null || m.senderAgentId != null) return true
|
|
523
|
+
if (m.taskSenderAgent || m.task_sender_agent || m.senderAgent || m.sender_agent || m.authorAgent || m.author_agent || m.actorAgent || m.actor_agent || m.createdByAgent || m.created_by_agent || m.agent || m.agent_id != null || m.sender_agent_id != null || m.senderAgentId != null || m.task_sender_agent_id != null || m.taskSenderAgentId != null || m.task_sender_agent_identifier != null || m.taskSenderAgentIdentifier != null || m.author_agent_id != null || m.authorAgentId != null || m.actor_agent_id != null || m.actorAgentId != null || m.created_by_agent_id != null || m.createdByAgentId != null) return true
|
|
505
524
|
const sender = [m.taskSender, m.task_sender, m.sender, m.user, m.author, m.member].find((item) => item && typeof item === 'object') || {}
|
|
506
525
|
if (sender.agent && typeof sender.agent === 'object') return true
|
|
507
526
|
const kind = String(sender.type ?? sender.kind ?? sender.sender_type ?? m.sender_type ?? m.author_type ?? '').toLowerCase()
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const TASK_MUTATION_TOOLS = new Set(['create_ticket', 'update_ticket', 'create_task', 'update_task'])
|
|
2
|
+
|
|
3
|
+
const TEXT_LIMITS = {
|
|
4
|
+
post_message: { fields: ['content', 'text', 'message', 'body'], max: 1200, label: 'Channel messages' },
|
|
5
|
+
create_task_comment: { fields: ['content', 'text', 'message', 'body'], max: 1200, label: 'Task comments' },
|
|
6
|
+
comment_ticket: { fields: ['content', 'text', 'message', 'body'], max: 1200, label: 'Task comments' },
|
|
7
|
+
create_ticket: { fields: ['description'], max: 2400, label: 'Task descriptions' },
|
|
8
|
+
update_ticket: { fields: ['description'], max: 2400, label: 'Task descriptions' },
|
|
9
|
+
create_task: { fields: ['description'], max: 2400, label: 'Task descriptions' },
|
|
10
|
+
update_task: { fields: ['description'], max: 2400, label: 'Task descriptions' },
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const OWNERSHIP_GUIDANCE = 'Task ownership fields are different: assignee_id is only for a human user/member id; agent_id is only for a registered coding agent id. Never put an agent id in assignee_id. Set at most one of these fields and clear the other when changing owner type.'
|
|
14
|
+
const CONCISE_GUIDANCE = 'Keep this text compact and easy to scan. Lead with the result or request, omit repeated context, and avoid open-ended invitations.'
|
|
15
|
+
|
|
16
|
+
function appendDescription(value, guidance) {
|
|
17
|
+
const current = String(value || '').trim()
|
|
18
|
+
return current.includes(guidance) ? current : [current, guidance].filter(Boolean).join(' ')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function stringBranches(schema) {
|
|
22
|
+
if (!schema || typeof schema !== 'object') return []
|
|
23
|
+
return [schema, ...(Array.isArray(schema.anyOf) ? schema.anyOf : []), ...(Array.isArray(schema.oneOf) ? schema.oneOf : [])]
|
|
24
|
+
.filter((item) => item && typeof item === 'object' && (item.type === 'string' || !item.type))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function setStringLimit(schema, max, guidance = CONCISE_GUIDANCE) {
|
|
28
|
+
if (!schema || typeof schema !== 'object') return
|
|
29
|
+
schema.description = appendDescription(schema.description, `${guidance} Maximum ${max} characters.`)
|
|
30
|
+
for (const branch of stringBranches(schema)) {
|
|
31
|
+
if (branch.type === 'string') branch.maxLength = Math.min(Number.isFinite(branch.maxLength) ? branch.maxLength : max, max)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function withAgentToolPolicy(tool) {
|
|
36
|
+
const copy = structuredClone(tool)
|
|
37
|
+
const name = String(copy?.name || '')
|
|
38
|
+
const schemas = [copy.inputSchema, copy.input_schema].filter((schema) => schema && typeof schema === 'object')
|
|
39
|
+
|
|
40
|
+
if (TASK_MUTATION_TOOLS.has(name)) {
|
|
41
|
+
copy.description = appendDescription(copy.description, OWNERSHIP_GUIDANCE)
|
|
42
|
+
for (const schema of schemas) {
|
|
43
|
+
const properties = schema.properties && typeof schema.properties === 'object' ? schema.properties : {}
|
|
44
|
+
if (properties.assignee_id) properties.assignee_id.description = appendDescription(properties.assignee_id.description, 'Human user/member id only. Never use an agent id here.')
|
|
45
|
+
if (properties.agent_id) properties.agent_id.description = appendDescription(properties.agent_id.description, 'Registered agent id only. Never use a human user id here.')
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const limit = TEXT_LIMITS[name]
|
|
50
|
+
if (limit) {
|
|
51
|
+
copy.description = appendDescription(copy.description, `${limit.label} must be concise. Maximum ${limit.max} characters.`)
|
|
52
|
+
for (const schema of schemas) {
|
|
53
|
+
const properties = schema.properties && typeof schema.properties === 'object' ? schema.properties : {}
|
|
54
|
+
for (const field of limit.fields) if (properties[field]) setStringLimit(properties[field], limit.max)
|
|
55
|
+
if (TASK_MUTATION_TOOLS.has(name) && properties.title) setStringLimit(properties.title, 160, 'Use a short, specific title.')
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return copy
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const present = (value) => value !== undefined && value !== null && value !== ''
|
|
62
|
+
|
|
63
|
+
export function validateAgentToolArguments(name, args = {}) {
|
|
64
|
+
const tool = String(name || '')
|
|
65
|
+
const input = args && typeof args === 'object' && !Array.isArray(args) ? args : {}
|
|
66
|
+
if (TASK_MUTATION_TOOLS.has(tool) && present(input.assignee_id) && present(input.agent_id)) {
|
|
67
|
+
throw new Error(`${OWNERSHIP_GUIDANCE} This call supplied both fields; choose the intended owner and retry once.`)
|
|
68
|
+
}
|
|
69
|
+
const limit = TEXT_LIMITS[tool]
|
|
70
|
+
if (limit) {
|
|
71
|
+
for (const field of limit.fields) {
|
|
72
|
+
if (!present(input[field])) continue
|
|
73
|
+
const length = String(input[field]).length
|
|
74
|
+
if (length > limit.max) throw new Error(`${limit.label} must be ${limit.max} characters or fewer; ${field} was ${length}. Rewrite it as a short result, request, or decision and retry once.`)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (TASK_MUTATION_TOOLS.has(tool) && present(input.title) && String(input.title).length > 160) {
|
|
78
|
+
throw new Error(`Task titles must be 160 characters or fewer; title was ${String(input.title).length}. Shorten it and retry once.`)
|
|
79
|
+
}
|
|
80
|
+
return input
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function compactAgentMessage(value, { maxChars = 1200 } = {}) {
|
|
84
|
+
const text = String(value || '').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
|
|
85
|
+
if (!text || text.length <= maxChars) return text
|
|
86
|
+
const suffix = '\n\n[Shortened for readability. Full output remains in Agent Studio.]'
|
|
87
|
+
const budget = Math.max(1, maxChars - suffix.length)
|
|
88
|
+
const candidate = text.slice(0, budget)
|
|
89
|
+
const minimum = Math.floor(budget * 0.65)
|
|
90
|
+
const stops = [candidate.lastIndexOf('\n\n'), candidate.lastIndexOf('. '), candidate.lastIndexOf('! '), candidate.lastIndexOf('? '), candidate.lastIndexOf(' ')]
|
|
91
|
+
const stop = stops.find((index) => index >= minimum)
|
|
92
|
+
return `${candidate.slice(0, stop >= minimum ? stop + (candidate[stop] === '.' || candidate[stop] === '!' || candidate[stop] === '?' ? 1 : 0) : budget).trimEnd()}${suffix}`
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const agentToolPolicy = Object.freeze({
|
|
96
|
+
ownershipGuidance: OWNERSHIP_GUIDANCE,
|
|
97
|
+
channelMessageMaxChars: TEXT_LIMITS.post_message.max,
|
|
98
|
+
taskCommentMaxChars: TEXT_LIMITS.create_task_comment.max,
|
|
99
|
+
taskDescriptionMaxChars: TEXT_LIMITS.create_ticket.max,
|
|
100
|
+
})
|
package/src/watch.mjs
CHANGED
|
@@ -10,12 +10,13 @@ import { fileURLToPath } from 'node:url'
|
|
|
10
10
|
import { join, dirname } from 'node:path'
|
|
11
11
|
import { OV_DIR, DEFAULT_WORKSPACE, readConfig, writeJson, configPath, onPath, fail, ok, info, slugify, stripSlash, chmodSafe } from './lib.mjs'
|
|
12
12
|
import { connectAgentWs, assertWebSocket } from './ws.mjs'
|
|
13
|
-
import { buildTaskCompletionReport, claudeEventEvidence, classifyConversationTarget, conversationAsksPendingTickets, conversationCreatesTicket, conversationNeedsCode, conversationRequestsChannelMessage, failedTaskRevisionIsCurrent, mentionDedupeKeys, messageParentId, messageSenderIsAgent, messageSenderIsSelf, normalizeRenderedMessageText, renderedAgentMessages, taskAgentId, taskAgentIdentifier, taskBelongsToAgent, taskCommentFromEvent, taskFromEvent, taskIsAwaitingReview, taskIsCompleted, taskIsCoordinationOnly, taskRevision, ticketDisplaySlug } from './events.mjs'
|
|
13
|
+
import { buildTaskCompletionReport, claudeEventEvidence, classifyConversationTarget, conversationAsksPendingTickets, conversationCreatesTicket, conversationNeedsCode, conversationRequestsChannelMessage, failedTaskRevisionIsCurrent, mentionDedupeKeys, messageParentId, messageSenderIsAgent, messageSenderIsSelf, normalizeRenderedMessageText, renderedAgentMessages, taskAgentId, taskAgentIdentifier, taskBelongsToAgent, taskCommentFromEvent, taskEventSenderIsSelf, taskFromEvent, taskIsAwaitingReview, taskIsCompleted, taskIsCoordinationOnly, taskRevision, ticketDisplaySlug } from './events.mjs'
|
|
14
14
|
import { WORK_SESSION_TOOL, workSessionRequest, agentProfileContext } from './runtime-control.mjs'
|
|
15
15
|
import { createMastraMemory } from './memory.mjs'
|
|
16
16
|
import { createContextService } from './context-service.mjs'
|
|
17
17
|
import { contextToolGuide } from './context-tools.mjs'
|
|
18
18
|
import { toolWithoutCredentialInputs } from './codex-mcp-proxy.mjs'
|
|
19
|
+
import { compactAgentMessage } from './tool-policy.mjs'
|
|
19
20
|
import { repositoryHasPrPushAuthorization, configurePrPublishing } from './pr-push.mjs'
|
|
20
21
|
import { createMcpHttpClient } from './mcp-http.mjs'
|
|
21
22
|
import { createCycleQueue } from './cycle-queue.mjs'
|
|
@@ -85,9 +86,12 @@ const REPLY_DISCIPLINE = [
|
|
|
85
86
|
' • 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.',
|
|
86
87
|
' • 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.',
|
|
87
88
|
' • 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.',
|
|
89
|
+
' • ASSIGNEE IDS ARE HUMAN IDS. On task mutations, assignee_id means a human user/member. agent_id means a registered agent. Never put an agent id in assignee_id, never put a human id in agent_id, and never send both. To assign work to yourself or another agent, resolve the registered agent and use agent_id.',
|
|
88
90
|
' • TICKET SLUGS, NEVER DATABASE IDS. In every human-facing channel message, ticket comment, PR description, summary, blocker, and result, reference a ticket by the exact project-scoped slug returned by get_ticket/list_tasks (for example, `OVS-57`). Numeric project_id and ticket_id values are internal MCP arguments only: never write `#57`, `ticket 57`, or expose a database id to teammates. If the backend omits the slug, use the ticket title or say “the ticket”; do not invent a slug.',
|
|
89
91
|
' • CLOSE CONCERNS. Never leave a concern, direct question, correction, or blocker addressed to you without a clear response. Acknowledge the concern, act if you can, then report the verified result. If blocked, name the blocker and the exact next action or owner in one message.',
|
|
90
92
|
' • HONOR CHANNEL DESTINATIONS. Reply to an existing request in its original thread. When a teammate explicitly asks you to send, post, announce, or share something in another channel, resolve that exact destination from live list_channels and post there. Use your dedicated agent channel for fresh top-level results, blockers, and introductions only when no other destination was requested. Never invent a channel id or silently substitute a different channel.',
|
|
93
|
+
' • KEEP MESSAGES COMPACT. Lead with the answer, result, decision, or blocker. Default to 1-4 short sentences or at most 6 brief bullets, normally under 600 characters and never over 1,200. Do not restate the prompt, narrate routine tool use, dump raw logs, or repeat context already visible in the thread. Task descriptions may use up to 2,400 characters when acceptance details require it.',
|
|
94
|
+
' • END AGENT-TO-AGENT TURNS. Answer one concrete peer-agent request once, without a reciprocal question, invitation, thank-you exchange, or status echo. Then wait for a human or a genuinely new human-directed request. Do not keep a conversation alive merely because another agent replied.',
|
|
91
95
|
' • WRITE PLAINLY. Prefer short sentences, commas, periods, and colons. Avoid em dashes except when reproducing quoted text.',
|
|
92
96
|
].join('\n')
|
|
93
97
|
|
|
@@ -619,7 +623,10 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
619
623
|
// Activity belongs to an individual cycle. Independent ticket workers must
|
|
620
624
|
// not clear or overwrite each other's status targets.
|
|
621
625
|
const replyStatusTargets = new Set()
|
|
622
|
-
const activity = dependencies.activityReporter || createActivityReporter({
|
|
626
|
+
const activity = dependencies.activityReporter || createActivityReporter({
|
|
627
|
+
backend, apiKey, identifier, log,
|
|
628
|
+
sendFrame: (frame) => handle?.send?.(frame) === true,
|
|
629
|
+
})
|
|
623
630
|
const sendStatus = (channelId, state) => {
|
|
624
631
|
const id = cycleContext.getStore()?.cycleId
|
|
625
632
|
const live = liveCycles.get(id)
|
|
@@ -1012,14 +1019,19 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1012
1019
|
// reached the server, so never try another comment endpoint after an error.
|
|
1013
1020
|
const name = names.has('create_task_comment') ? 'create_task_comment' : names.has('comment_ticket') ? 'comment_ticket' : ''
|
|
1014
1021
|
if (!name) return { called: false, reason: 'not-advertised' }
|
|
1022
|
+
const message = compactAgentMessage(content, { maxChars: 1200 })
|
|
1015
1023
|
const args = {
|
|
1016
1024
|
project_id: projectId,
|
|
1017
1025
|
ticket_id: ticketId,
|
|
1018
1026
|
...(name === 'create_task_comment'
|
|
1019
|
-
? { content, ...(validBackendId(parentId) ? { parent_id: Number(parentId) } : {}) }
|
|
1020
|
-
: { text:
|
|
1027
|
+
? { content: message, ...(validBackendId(parentId) ? { parent_id: Number(parentId) } : {}) }
|
|
1028
|
+
: { text: message }),
|
|
1029
|
+
}
|
|
1030
|
+
try {
|
|
1031
|
+
const result = await callMcpTool(name, args)
|
|
1032
|
+
memory.remember({ key: ownTaskCommentKey(projectId, ticketId, message), kind: 'task-comment', state: 'rendered', summary: message, refs: { projectId: Number(projectId), ticketId: Number(ticketId), ...(validBackendId(parentId) ? { threadId: Number(parentId) } : {}) } })
|
|
1033
|
+
return { called: true, tool: name, result }
|
|
1021
1034
|
}
|
|
1022
|
-
try { return { called: true, tool: name, result: await callMcpTool(name, args) } }
|
|
1023
1035
|
catch (e) {
|
|
1024
1036
|
if (/\b(?:unknown|missing|unsupported) tool\b|\btool\b.*\bnot found\b/i.test(String(e?.message || e))) {
|
|
1025
1037
|
try { await discoverMcpTools(true) } catch { /* the original error is enough */ }
|
|
@@ -1152,7 +1164,15 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1152
1164
|
identity: () => ({ id: selfAgentId, identifier, slug, name: slug }),
|
|
1153
1165
|
load: async (cid, root) => toolData(await callMcpTool('list_message_thread', { channel_id: Number(cid), message_id: Number(root) })),
|
|
1154
1166
|
})
|
|
1155
|
-
const
|
|
1167
|
+
const peerTurnKey = (surface, ...parts) => `peer-turn:${surface}:${parts.map((value) => String(value ?? '?')).join(':')}`
|
|
1168
|
+
const ownTaskCommentKey = (projectId, ticketId, content) => {
|
|
1169
|
+
const normalized = normalizeRenderedMessageText(content)
|
|
1170
|
+
return `own-task-comment:${projectId}:${ticketId}:${normalized.length}:${normalized.slice(0, 120)}:${normalized.slice(-120)}`
|
|
1171
|
+
}
|
|
1172
|
+
const rememberPeerReply = (key, refs = {}) => {
|
|
1173
|
+
if (key) memory.remember({ key, kind: 'peer-turn', state: 'replied', summary: 'Replied once to a peer agent; waiting for a human turn.', refs })
|
|
1174
|
+
}
|
|
1175
|
+
const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '', sourceMessageId, allowCancelled = false, peerLoopKey = '', isCancelled = () => false }) => {
|
|
1156
1176
|
if (stopping) throw new Error('Watcher is stopping')
|
|
1157
1177
|
const deliveryKey = String(key || '')
|
|
1158
1178
|
if (parentId == null && !validBackendId(channelId)) {
|
|
@@ -1160,7 +1180,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1160
1180
|
if (channelId == null) throw new Error('No dedicated agent channel available for a new thread')
|
|
1161
1181
|
projectId = undefined // dedicated channels may be organization-scoped
|
|
1162
1182
|
}
|
|
1163
|
-
const message =
|
|
1183
|
+
const message = compactAgentMessage(content, { maxChars: 1200 })
|
|
1164
1184
|
if (!deliveryKey || !validBackendId(channelId) || (parentId != null && !validBackendId(parentId)) || !message) return { posted: false, reason: 'invalid-delivery' }
|
|
1165
1185
|
if (deliveredReplies.has(deliveryKey) || memory.has(deliveryKey, 'rendered')) {
|
|
1166
1186
|
if (pendingReplyDeliveries.delete(deliveryKey)) persistReplay()
|
|
@@ -1183,7 +1203,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1183
1203
|
// Store generated threaded replies before delivery. A transport outage must
|
|
1184
1204
|
// retry the authoritative delivery check, not pay for another model turn.
|
|
1185
1205
|
if (parentId != null) {
|
|
1186
|
-
pendingReplyDeliveries.set(deliveryKey, { key, channelId, parentId, projectId, content: message, skipIfAnyAgentReply, sourceKey, sourceMessageId, allowCancelled })
|
|
1206
|
+
pendingReplyDeliveries.set(deliveryKey, { key, channelId, parentId, projectId, content: message, skipIfAnyAgentReply, sourceKey, sourceMessageId, allowCancelled, peerLoopKey })
|
|
1187
1207
|
persistReplay()
|
|
1188
1208
|
}
|
|
1189
1209
|
const run = (async () => {
|
|
@@ -1196,6 +1216,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1196
1216
|
deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
|
|
1197
1217
|
memory.remember({ key: deliveryKey, kind: 'delivery', state: 'rendered', summary: duplicate ? message : rendered.at(-1)?.content, refs: { channelId: Number(channelId), threadId: Number(parentId) }, meta: { discoveredFromBackend: true } })
|
|
1198
1218
|
if (sourceKey) memory.connect(deliveryKey, sourceKey, 'responds_to')
|
|
1219
|
+
rememberPeerReply(peerLoopKey, { channelId: Number(channelId), threadId: Number(parentId) })
|
|
1199
1220
|
log('message delivery ' + deliveryKey + ' already rendered — skipped')
|
|
1200
1221
|
return { posted: false, reason: duplicate ? 'same-content-rendered' : 'agent-reply-rendered' }
|
|
1201
1222
|
}
|
|
@@ -1220,6 +1241,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1220
1241
|
deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
|
|
1221
1242
|
memory.remember({ key: deliveryKey, kind: 'delivery', state: 'rendered', summary: message, refs: { channelId: Number(channelId), ...(parentId != null ? { threadId: Number(parentId) } : {}), ...(Number.isFinite(Number(projectId)) ? { projectId: Number(projectId) } : {}) }, meta: { messageId: result.id ?? result.message?.id ?? null } })
|
|
1222
1243
|
if (sourceKey) memory.connect(deliveryKey, sourceKey, 'responds_to')
|
|
1244
|
+
rememberPeerReply(peerLoopKey, { channelId: Number(channelId), ...(parentId != null ? { threadId: Number(parentId) } : {}) })
|
|
1223
1245
|
return { posted: true, result }
|
|
1224
1246
|
})().then((result) => {
|
|
1225
1247
|
if (pendingReplyDeliveries.delete(deliveryKey)) persistReplay()
|
|
@@ -1232,10 +1254,10 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1232
1254
|
// Task comments have no channel thread to inspect before delivery. Record the
|
|
1233
1255
|
// delivery attempt before the mutation and use the comment id as the one-level
|
|
1234
1256
|
// thread root. An ambiguous mutation error is never replayed automatically.
|
|
1235
|
-
const postTaskCommentOnce = async ({ key, projectId, ticketId, parentId, content, sourceKey = '' }) => {
|
|
1257
|
+
const postTaskCommentOnce = async ({ key, projectId, ticketId, parentId, content, sourceKey = '', peerLoopKey = '' }) => {
|
|
1236
1258
|
if (stopping) throw new Error('Watcher is stopping')
|
|
1237
1259
|
const deliveryKey = String(key || '')
|
|
1238
|
-
const message =
|
|
1260
|
+
const message = compactAgentMessage(content, { maxChars: 1200 })
|
|
1239
1261
|
if (!deliveryKey || !validBackendId(projectId) || !validBackendId(ticketId) || !validBackendId(parentId) || !message) return { posted: false, reason: 'invalid-delivery' }
|
|
1240
1262
|
if (deliveredReplies.has(deliveryKey) || memory.has(deliveryKey, 'rendered')) {
|
|
1241
1263
|
return { posted: false, reason: 'remembered' }
|
|
@@ -1250,7 +1272,9 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1250
1272
|
throw new Error('No task-comment tool is advertised by the backend')
|
|
1251
1273
|
}
|
|
1252
1274
|
memory.remember({ key: deliveryKey, kind: 'delivery', state: 'rendered', summary: message, refs: { projectId: Number(projectId), ticketId: Number(ticketId), threadId: Number(parentId) }, meta: { surface: 'task-comment' } })
|
|
1275
|
+
memory.remember({ key: ownTaskCommentKey(projectId, ticketId, message), kind: 'task-comment', state: 'rendered', summary: message, refs: { projectId: Number(projectId), ticketId: Number(ticketId), threadId: Number(parentId) } })
|
|
1253
1276
|
if (sourceKey) memory.connect(deliveryKey, sourceKey, 'responds_to')
|
|
1277
|
+
rememberPeerReply(peerLoopKey, { projectId: Number(projectId), ticketId: Number(ticketId), threadId: Number(parentId) })
|
|
1254
1278
|
return { posted: true, result: result.result }
|
|
1255
1279
|
})().finally(() => messageDeliveries.delete(deliveryKey))
|
|
1256
1280
|
messageDeliveries.set(deliveryKey, run)
|
|
@@ -1259,9 +1283,9 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1259
1283
|
|
|
1260
1284
|
async function publishWorkPlan(live, entries, acknowledgement = '') {
|
|
1261
1285
|
const { control, delivery, taskRef } = live
|
|
1262
|
-
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,
|
|
1286
|
+
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, 160))
|
|
1263
1287
|
if (!steps.length || control.cancelled || !control.planKey || memory.has(control.planKey)) return
|
|
1264
|
-
const introduction = String(acknowledgement || '').trim().slice(0,
|
|
1288
|
+
const introduction = String(acknowledgement || '').trim().slice(0, 320) || (control.taskLabel ? `I'm on ${control.taskLabel}.` : "I'll take this on.")
|
|
1265
1289
|
const content = `${introduction}\n\n${steps.map((step, index) => `${index + 1}. ${step}`).join('\n')}`
|
|
1266
1290
|
const channelId = delivery?.channelId ?? taskRef?.channelId ?? await projectStatusChannel(taskRef?.projectId)
|
|
1267
1291
|
if (channelId == null || control.cancelled) return
|
|
@@ -1831,6 +1855,13 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1831
1855
|
const ticketId = hinted && (hinted.id ?? hinted.task_id ?? hinted.taskId)
|
|
1832
1856
|
const projectId = hinted && (hinted.project_id ?? hinted.projectId ?? raw.project_id ?? raw.projectId)
|
|
1833
1857
|
if (!validBackendId(ticketId) || !validBackendId(projectId)) { log(kind + ' missing ticket/project identity — ignored'); return }
|
|
1858
|
+
if (kind === 'task:updated') {
|
|
1859
|
+
try { await ensureSelfAgent() } catch { /* stable identifier may still prove authorship */ }
|
|
1860
|
+
if (taskEventSenderIsSelf(raw, { id: selfAgentId, identifier, slug, name })) {
|
|
1861
|
+
log(kind + ' ticket #' + ticketId + ' was updated by this agent — ignored')
|
|
1862
|
+
return { status: 'ignored-self' }
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1834
1865
|
try {
|
|
1835
1866
|
const ticketData = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
|
|
1836
1867
|
const ticket = ticketData.ticket ?? ticketData.task ?? ticketData
|
|
@@ -1914,7 +1945,19 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1914
1945
|
}
|
|
1915
1946
|
try { await ensureSelfAgent() }
|
|
1916
1947
|
catch (error) { log('agent identity lookup unavailable; using configured aliases: ' + (error?.message || error)) }
|
|
1948
|
+
const rootCommentId = messageParentId(comment) ?? Number(commentId)
|
|
1949
|
+
if (memory.has(ownTaskCommentKey(projectId, ticketId, text), 'rendered')) {
|
|
1950
|
+
log(`task:comment project=${projectId} ticket=${ticketId} matches this agent's delivered comment — ignored`)
|
|
1951
|
+
return { status: 'ignored-self' }
|
|
1952
|
+
}
|
|
1917
1953
|
if (messageSenderIsSelf(comment, { id: selfAgentId, identifier, slug, name })) return { status: 'ignored-self' }
|
|
1954
|
+
const peer = messageSenderIsAgent(comment)
|
|
1955
|
+
const loopKey = peerTurnKey('task-comment', projectId, ticketId, rootCommentId)
|
|
1956
|
+
if (peer && memory.has(loopKey, 'replied')) {
|
|
1957
|
+
log(`task:comment project=${projectId} ticket=${ticketId} ignored after one peer-agent reply; waiting for a human turn`)
|
|
1958
|
+
return { status: 'ignored-peer-loop' }
|
|
1959
|
+
}
|
|
1960
|
+
if (!peer) memory.remember({ key: loopKey, kind: 'peer-turn', state: 'human', summary: text, refs: { projectId: Number(projectId), ticketId: Number(ticketId), threadId: Number(rootCommentId) } })
|
|
1918
1961
|
const target = classifyConversationTarget(comment, [...selfAliases], { threadOwned: true })
|
|
1919
1962
|
if (target.action !== 'handle') {
|
|
1920
1963
|
log('task:comment ignored before model start (' + target.reason + ')')
|
|
@@ -1936,9 +1979,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1936
1979
|
log(`task:comment ticket #${ticketId} is not assigned to this agent — ignored`)
|
|
1937
1980
|
return { status: 'ignored-unassigned' }
|
|
1938
1981
|
}
|
|
1939
|
-
const rootCommentId = messageParentId(comment) ?? Number(commentId)
|
|
1940
1982
|
const who = senderName(comment)
|
|
1941
|
-
const peer = messageSenderIsAgent(comment)
|
|
1942
1983
|
const ticketSlug = ticketDisplaySlug(ticket)
|
|
1943
1984
|
memory.remember({ key: sourceKey, kind: 'task-comment', state: 'received', summary: text, refs: { projectId: Number(projectId), ticketId: Number(ticketId), messageId: Number(commentId), threadId: Number(rootCommentId) } })
|
|
1944
1985
|
const delivery = {
|
|
@@ -1949,6 +1990,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1949
1990
|
parentId: Number(rootCommentId),
|
|
1950
1991
|
sourceKey,
|
|
1951
1992
|
watcherOwned: true,
|
|
1993
|
+
peerLoopKey: peer ? loopKey : '',
|
|
1952
1994
|
}
|
|
1953
1995
|
const label = ticketSlug || String(ticket.title || 'the assigned ticket')
|
|
1954
1996
|
const context = `The watcher received a new comment on your assigned ticket ${JSON.stringify(label)} from ${JSON.stringify(who || (peer ? 'another agent' : 'a teammate'))}: ${JSON.stringify(text)}. Authoritative ticket details: ${JSON.stringify({ slug: ticketSlug, title: ticket.title, description: ticket.description, priority: ticket.priority, status: ticket.type?.name ?? ticket.task_type?.name ?? ticket.status ?? ticket.state })}. Answer the comment directly in the context of this ticket. Do not call post_message or any task-comment tool; return only the final reply and the watcher will post it once in the source comment thread.${peer ? ' This was authored by another team agent. Address its concrete question or request without replacing it with ticket inventory or adding an open-ended reciprocal question.' : ''}`
|
|
@@ -2071,13 +2113,19 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2071
2113
|
if (!threadOwned || memory.has(sourceKey, 'received')) { log('agent:mention (dup) — skipped'); return }
|
|
2072
2114
|
log('agent:mention replay recovered for active owned thread')
|
|
2073
2115
|
}
|
|
2116
|
+
const loopKey = cid != null && threadRoot != null ? peerTurnKey('channel', cid, threadRoot) : ''
|
|
2117
|
+
if (peerMessage && loopKey && memory.has(loopKey, 'replied')) {
|
|
2118
|
+
log('agent:mention ignored before model start (peer-loop-budget)')
|
|
2119
|
+
return { status: 'ignored-peer-loop' }
|
|
2120
|
+
}
|
|
2121
|
+
if (!peerMessage && loopKey) memory.remember({ key: loopKey, kind: 'peer-turn', state: 'human', summary: text, refs: { channelId: Number(cid), threadId: Number(threadRoot) } })
|
|
2074
2122
|
const target = classifyConversationTarget(msg, [...selfAliases], { threadOwned })
|
|
2075
2123
|
if (target.action === 'ignore') {
|
|
2076
2124
|
log('agent:mention ignored before model start (' + target.reason + ')')
|
|
2077
|
-
//
|
|
2078
|
-
//
|
|
2079
|
-
//
|
|
2080
|
-
if (cid != null && threadRoot != null && (target.reason === 'explicit-other-recipient' || target.reason === '
|
|
2125
|
+
// Ordinary peer chatter is additional thread context, not cancellation.
|
|
2126
|
+
// Only a message whose recipient analysis shows an actual handoff may
|
|
2127
|
+
// invalidate older work; explicit stand-down is handled below.
|
|
2128
|
+
if (cid != null && threadRoot != null && (target.reason === 'explicit-other-recipient' || target.reason === 'redirected-to-later-agent')) {
|
|
2081
2129
|
cancelThread(cid, threadRoot, text || target.reason)
|
|
2082
2130
|
}
|
|
2083
2131
|
return
|
|
@@ -2099,7 +2147,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2099
2147
|
// redirect can cancel its process. The watcher delivers the final answer
|
|
2100
2148
|
// for every runtime after an authoritative last-moment thread check.
|
|
2101
2149
|
const conversationDelivery = (stage = 'reply', skipIfAnyAgentReply = stage !== 'result', allowChannelMessaging = false) => cid != null
|
|
2102
|
-
? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey, sourceMessageId: mid, watcherOwned: true, allowChannelMessaging }
|
|
2150
|
+
? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey, sourceMessageId: mid, watcherOwned: true, allowChannelMessaging, peerLoopKey: peerMessage ? loopKey : '' }
|
|
2103
2151
|
: null
|
|
2104
2152
|
// Under-the-hood model control from chat (view / switch the model the agent runs).
|
|
2105
2153
|
const mcmd = cid != null ? parseModelCmd(text) : null
|
package/src/ws.mjs
CHANGED
|
@@ -18,6 +18,31 @@ const KEEPALIVE_MS = 30_000 // refresh last_seen_at well within any idle timeout
|
|
|
18
18
|
const BACKOFF_MIN_MS = 1_000
|
|
19
19
|
const BACKOFF_MAX_MS = 30_000
|
|
20
20
|
|
|
21
|
+
const own = (value, key) => Object.prototype.hasOwnProperty.call(value, key)
|
|
22
|
+
|
|
23
|
+
/** Accept the documented `{k,d}` event and compatible backend relay envelopes.
|
|
24
|
+
* Keeping this at the transport boundary prevents provider/watch logic from
|
|
25
|
+
* learning several wire formats. */
|
|
26
|
+
export function normalizeAgentWsEvent(raw) {
|
|
27
|
+
let msg = raw
|
|
28
|
+
if (typeof msg === 'string') { try { msg = JSON.parse(msg) } catch { return null } }
|
|
29
|
+
if (!msg || typeof msg !== 'object' || Array.isArray(msg)) return null
|
|
30
|
+
if (!msg.k && !msg.kind && !msg.event && !msg.event_type && !msg.eventType && !msg.type && own(msg, 'body')) {
|
|
31
|
+
return normalizeAgentWsEvent(msg.body)
|
|
32
|
+
}
|
|
33
|
+
const kind = [msg.k, msg.kind, msg.event, msg.event_type, msg.eventType, msg.type].find(value => typeof value === 'string' && value.trim())
|
|
34
|
+
if (!kind || kind === 'event') return null
|
|
35
|
+
let payload
|
|
36
|
+
if (own(msg, 'd')) payload = msg.d
|
|
37
|
+
else if (own(msg, 'payload')) payload = msg.payload
|
|
38
|
+
else if (own(msg, 'data')) payload = msg.data
|
|
39
|
+
else {
|
|
40
|
+
const { k: _k, kind: _kind, event: _event, event_type: _eventType, eventType: _eventTypeCamel, type: _type, ...body } = msg
|
|
41
|
+
payload = body
|
|
42
|
+
}
|
|
43
|
+
return { kind, payload, envelope: msg }
|
|
44
|
+
}
|
|
45
|
+
|
|
21
46
|
/** Bail early with a friendly message if the runtime has no global WebSocket. */
|
|
22
47
|
export function assertWebSocket(fail) {
|
|
23
48
|
if (typeof globalThis.WebSocket === 'function') return
|
|
@@ -86,13 +111,12 @@ export function connectAgentWs({ wsUrl, apiKey, identifier, onEvent, onConnect,
|
|
|
86
111
|
|
|
87
112
|
sock.addEventListener('message', (ev) => {
|
|
88
113
|
if (sock !== ws) return
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const kind = msg && msg.k
|
|
114
|
+
const event = normalizeAgentWsEvent(typeof ev.data === 'string' ? ev.data : String(ev.data))
|
|
115
|
+
const kind = event?.kind
|
|
92
116
|
if (!kind || kind === 'keepalive:ack') return // keepalive round-trip, nothing to do
|
|
93
117
|
// Error metadata often lives on the JSON envelope rather than `d`. Preserve
|
|
94
118
|
// it so callers do not receive an unhelpful empty object.
|
|
95
|
-
try { onEvent(kind, kind === 'error' ?
|
|
119
|
+
try { onEvent(kind, kind === 'error' ? event.envelope : event.payload) } catch (e) { log('event handler error: ' + (e && e.message ? e.message : e)) }
|
|
96
120
|
})
|
|
97
121
|
|
|
98
122
|
const down = (why) => {
|
|
@@ -119,8 +143,14 @@ export function connectAgentWs({ wsUrl, apiKey, identifier, onEvent, onConnect,
|
|
|
119
143
|
subscribe()
|
|
120
144
|
return true
|
|
121
145
|
},
|
|
122
|
-
/** Fire-and-forget send
|
|
123
|
-
send(obj) {
|
|
146
|
+
/** Fire-and-forget send. Returns whether the frame reached an open socket. */
|
|
147
|
+
send(obj) {
|
|
148
|
+
try {
|
|
149
|
+
if (!ws || ws.readyState !== 1) return false
|
|
150
|
+
ws.send(JSON.stringify(obj))
|
|
151
|
+
return true
|
|
152
|
+
} catch { return false }
|
|
153
|
+
},
|
|
124
154
|
close() {
|
|
125
155
|
closed = true
|
|
126
156
|
clearKeepalive()
|
package/studio/style.css
CHANGED
|
@@ -139,18 +139,19 @@
|
|
|
139
139
|
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 4px 2px 15px; min-height: 37px; }
|
|
140
140
|
.section-heading h2 { font-size: 12px; }
|
|
141
141
|
.section-heading .muted { font-size: 12px; }
|
|
142
|
-
.activity-list { display: flex; flex-direction: column; gap:
|
|
143
|
-
.activity-row { display: flex; align-items:
|
|
142
|
+
.activity-list { display: flex; flex-direction: column; gap: 2px; max-height: min(67vh, 810px); min-height: 380px; overflow: auto; overscroll-behavior: contain; scrollbar-width: thin; padding: 2px; }
|
|
143
|
+
.activity-row { display: flex; align-items: center; gap: 8px; text-align: left; width: 100%; min-height: 40px; padding: 6px 9px; border: 1px solid transparent; border-radius: 12px; corner-shape: squircle; background: transparent; }
|
|
144
144
|
.activity-row:hover { background: var(--hover); }
|
|
145
145
|
.activity-row.is-selected { background: var(--surface); border-color: transparent; box-shadow: 0 1px 2px #00000006; }
|
|
146
|
-
.event-icon { width:
|
|
147
|
-
.event-icon .icon { width:
|
|
148
|
-
.event-body { min-width: 0; flex: 1; }
|
|
149
|
-
.event-title {
|
|
150
|
-
.event-description {
|
|
151
|
-
.event-meta { display: flex; align-items: center; flex-wrap:
|
|
152
|
-
.event-meta .badge {
|
|
153
|
-
.event-
|
|
146
|
+
.event-icon { width: 27px; height: 27px; display: grid; place-items: center; flex-shrink: 0; color: var(--muted); background: var(--surface); border: 1px solid var(--border); border-radius: 8px; corner-shape: squircle; }
|
|
147
|
+
.event-icon .icon { width: 14px; height: 14px; }
|
|
148
|
+
.event-body { min-width: 0; flex: 1; display: flex; align-items: center; gap: 7px; overflow: hidden; }
|
|
149
|
+
.event-title { flex: 0 1 auto; max-width: 38%; font-size: 12px; font-weight: 500; line-height: 1.35; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
150
|
+
.event-description { flex: 1 1 auto; min-width: 24px; overflow: hidden; font-size: 11px; font-weight: 400; color: var(--muted); margin: 0; line-height: 1.35; white-space: nowrap; text-overflow: ellipsis; }
|
|
151
|
+
.event-meta { display: flex; align-items: center; flex: 0 1 auto; min-width: 0; flex-wrap: nowrap; gap: 5px; font-size: 11px; font-weight: 400; color: var(--muted); margin: 0; white-space: nowrap; overflow: hidden; }
|
|
152
|
+
.event-meta > span:not(.badge) { max-width: 86px; overflow: hidden; text-overflow: ellipsis; }
|
|
153
|
+
.event-meta .badge { font-size: 10px; padding: 1px 5px; }
|
|
154
|
+
.event-time { font-size: 10px; color: var(--subtle); white-space: nowrap; font-weight: 400; font-family: var(--font-mono); margin: 0; }
|
|
154
155
|
.badge { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; border-radius: 999px; background: var(--hover); color: var(--muted); font-size: 12px; font-weight: 400; white-space: nowrap; }
|
|
155
156
|
.badge .dot { width: 4px; height: 4px; }
|
|
156
157
|
.badge.active, .badge.completed, .badge.online { color: var(--success); }
|
|
@@ -353,10 +354,10 @@
|
|
|
353
354
|
.guide-callout { padding: 18px; border-radius: var(--radius-md); background: var(--stage); }
|
|
354
355
|
@media print { .guide-nav, .guide-header .button, .skip-link { display: none; } .guide-layout { display: block; } .guide-shell { max-width: none; padding: 0; } .guide-content section { break-inside: avoid; } }
|
|
355
356
|
@media (max-width: 640px) { .guide-shell { padding: 24px 20px; } .guide-layout { grid-template-columns: minmax(0, 1fr); gap: 24px; } .guide-nav { position: static; } .panel-toolbar { flex-wrap: wrap; } }
|
|
356
|
-
@media (min-width: 1550px) { .workspace { padding-bottom: 28px; } .content-grid { grid-template-columns: minmax(280px, .85fr) minmax(360px, 1.15fr); gap: 24px; } .activity-row { padding:
|
|
357
|
+
@media (min-width: 1550px) { .workspace { padding-bottom: 28px; } .content-grid { grid-template-columns: minmax(280px, .85fr) minmax(360px, 1.15fr); gap: 24px; } .activity-row { padding: 6px 9px; } .inspector { padding: 2px; } }
|
|
357
358
|
@media (max-width: 1120px) { .app-shell { grid-template-columns: 185px minmax(0, 1fr); gap: 20px; width: calc(100% - 40px); } .sidebar { padding-inline: 10px; } .workspace { padding: 8px 0 18px; } .content-grid { grid-template-columns: minmax(250px, 1fr) minmax(270px, 1fr); } .metrics { gap: 8px; } .header-actions { gap: 10px; } .metric-caption { font-size: 12px; } }
|
|
358
359
|
@media (max-width: 940px) { .content-grid { grid-template-columns: minmax(0, 1fr); } .inspector { max-height: none; min-height: 220px; } .inspector-empty { min-height: 200px; } .activity-list { min-height: 250px; max-height: 440px; } .metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .page-header { align-items: flex-start; } .header-actions { flex-direction: column; align-items: flex-end; padding-top: 7px; gap: 13px; } .page-description { max-width: 230px; } }
|
|
359
|
-
@media (max-width: 640px) { .app-shell { grid-template-columns: minmax(0, 1fr); width: 100%; margin: 0 auto; gap: 0; min-height: 100dvh; } .sidebar { position: static; height: auto; padding: 16px 18px 8px; border: 0; border-radius: 0; overflow-y: visible; } .brand { margin: 0 0 16px; font-size: 13px; } .brand-caption { display: none; } .brand-mark { width: 28px; height: 28px; } .brand-mark img { width: 56px; height: 56px; } .sidebar-title, .agent-search, .sidebar-note { display: none; } .agent-list { flex-direction: row; overflow-x: auto; padding: 2px 2px 7px; gap: 6px; } .agent-item { width: auto; min-width: 132px; max-width: 170px; padding: 8px; flex-shrink: 0; } .agent-avatar { width: 25px; height: 25px; font-size: 12px; } .agent-name { font-size: 12px; } .agent-caption { font-size: 12px; } .workspace { padding: 17px 18px 20px; } .page-header { margin-bottom: 22px; gap: 10px; } h1 { font-size: 27px; } .page-description { font-size: 12px; max-width: 210px; } .eyebrow { font-size: 12px; } .header-actions .button { min-height: 40px; font-size: 12px; padding: 6px 10px; } .connection-status { font-size: 12px; } .metrics { margin-bottom: 22px; } .metric-value { font-size: 27px; } .metric-label { font-size: 12px; } .panel-toolbar { gap: 8px; } .tab { padding: 7px 9px; font-size: 12px; gap: 5px; } .tab .icon { width: 12px; height: 12px; } .follow-control { font-size: 12px; gap: 4px; } .filters { flex-wrap: wrap; gap: 7px; } .search-field { flex-basis: 100%; } .select-field { flex: 1; } .select-field select { font-size: 12px; } .content-grid { gap: 20px; } .page-footer { flex-direction: column; gap: 4px; } .event-time { font-size:
|
|
360
|
+
@media (max-width: 640px) { .app-shell { grid-template-columns: minmax(0, 1fr); width: 100%; margin: 0 auto; gap: 0; min-height: 100dvh; } .sidebar { position: static; height: auto; padding: 16px 18px 8px; border: 0; border-radius: 0; overflow-y: visible; } .brand { margin: 0 0 16px; font-size: 13px; } .brand-caption { display: none; } .brand-mark { width: 28px; height: 28px; } .brand-mark img { width: 56px; height: 56px; } .sidebar-title, .agent-search, .sidebar-note { display: none; } .agent-list { flex-direction: row; overflow-x: auto; padding: 2px 2px 7px; gap: 6px; } .agent-item { width: auto; min-width: 132px; max-width: 170px; padding: 8px; flex-shrink: 0; } .agent-avatar { width: 25px; height: 25px; font-size: 12px; } .agent-name { font-size: 12px; } .agent-caption { font-size: 12px; } .workspace { padding: 17px 18px 20px; } .page-header { margin-bottom: 22px; gap: 10px; } h1 { font-size: 27px; } .page-description { font-size: 12px; max-width: 210px; } .eyebrow { font-size: 12px; } .header-actions .button { min-height: 40px; font-size: 12px; padding: 6px 10px; } .connection-status { font-size: 12px; } .metrics { margin-bottom: 22px; } .metric-value { font-size: 27px; } .metric-label { font-size: 12px; } .panel-toolbar { gap: 8px; } .tab { padding: 7px 9px; font-size: 12px; gap: 5px; } .tab .icon { width: 12px; height: 12px; } .follow-control { font-size: 12px; gap: 4px; } .filters { flex-wrap: wrap; gap: 7px; } .search-field { flex-basis: 100%; } .select-field { flex: 1; } .select-field select { font-size: 12px; } .content-grid { gap: 20px; } .page-footer { flex-direction: column; gap: 4px; } .event-time { font-size: 10px; } .activity-row { gap: 6px; padding: 6px; } .event-description { display: none; } .inspector { padding: 2px; } }
|
|
360
361
|
.model-settings-summary:not([hidden]) { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 14px; padding: 18px; margin-bottom: 22px; border: 1px solid var(--border); border-radius: 20px; background: var(--surface, white); }
|
|
361
362
|
.model-settings-summary h2 { margin: 0 0 6px; font-size: 13px; font-weight: 500; }
|
|
362
363
|
.model-settings-summary p { margin: 0; color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
|