openvisio-agent 0.25.5 → 0.25.7
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 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/scenarios/workspace.scenarios.mjs +2 -2
- package/scripts/certify.mjs +4 -1
- package/src/agent-journal.mjs +1 -4
- package/src/studio-server.mjs +6 -1
- package/src/watch.mjs +109 -99
- package/studio/app.mjs +1 -1
- package/studio/guide.html +2 -2
- package/studio/index.html +5 -1
- package/studio/style.css +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## [0.25.7] — 2026-09-21
|
|
6
|
+
|
|
7
|
+
- Restore recovery for addressed messages that the backend omits from the watcher WebSocket stream.
|
|
8
|
+
- Cache projects during connection recovery, then check only `list_activity` every 30 seconds while idle. The lightweight check does not rescan projects, tasks, boards, or task types and starts no model provider unless it finds a new addressed message.
|
|
9
|
+
- Add a behavioral regression test proving a missed mention is handled once without idle project or task scans.
|
|
10
|
+
|
|
11
|
+
## [0.25.6] — 2026-09-21
|
|
12
|
+
|
|
13
|
+
- Keep healthy backend watchers event-driven after their connection recovery pass instead of polling every project for tasks and activity every 30 seconds.
|
|
14
|
+
- Stop idle journal heartbeats, derive Studio liveness from watcher PID locks, and apply model-setting changes through filesystem notifications instead of two-second polling.
|
|
15
|
+
- Add semantic icons and status colors to Agent Studio's watcher, model-settings, and agent-removal actions.
|
|
16
|
+
|
|
5
17
|
## [0.25.5] — 2026-09-21
|
|
6
18
|
|
|
7
19
|
- Add Gemini CLI and Qwen Code as coding-agent providers through their native ACP modes.
|
package/README.md
CHANGED
|
@@ -81,7 +81,7 @@ The integration follows Mastra's [semantic memory](https://mastra.ai/docs/memory
|
|
|
81
81
|
|
|
82
82
|
Model selections are stable. Exact IDs (including reasoning effort) are preserved. An unsuffixed ACP model may resolve once to the same model with `medium` effort; the watcher saves that exact selection. Unavailable selections produce an explicit error rather than switching generations or families. Claude versioned IDs are preserved, with automatic family fallbacks disabled. Codex uses the configured chat model for replies.
|
|
83
83
|
|
|
84
|
-
Backend/BYO watchers reconcile
|
|
84
|
+
Backend/BYO watchers reconcile assignments whenever the WebSocket starts or reconnects. A direct MCP session discovers and caches the backend's actual `tools/list` response, then uses available tools such as `list_agents`, `list_projects`, `list_tasks`, `list_task_types`, and `list_activity` to recover work missed while offline. Because the backend can omit an addressed message from the watcher stream, the watcher retains the recovered project IDs and checks only recent `list_activity` every 30 seconds; it does not repeat project, board, task-type, or task scans while idle. Team discovery is shared by every runtime provider: structured MCP results and compact Markdown list envelopes are normalized once before the watcher resolves its project, identity, boards, tickets, or dedicated channel. Adding another model provider therefore reuses the same saved team connection and setup path instead of adding provider-specific project discovery. Optional actions such as ticket comments are used only when advertised; their absence cannot strand a completed ticket in a retry loop. The activity check starts a model only when it finds a new addressed message.
|
|
85
85
|
|
|
86
86
|
The backend MCP may be stateful or stateless. A successful initialize response without `Mcp-Session-Id` is accepted as stateless, so OpenCode agents do not stop with “MCP initialize returned no session id.” Each OpenCode lane keeps its MCP identity in a private per-agent config directory while the repository is supplied separately with `--dir`; stale workspace configuration therefore cannot swap one agent's credentials for another's. The generated remote configuration sends the agent headers directly, disables OAuth probing, and backend cycles never request relay-only inbox calls or MCP resource-discovery tools in place of team actions.
|
|
87
87
|
|
|
@@ -114,7 +114,7 @@ Authorization recovery checks paused repositories locally every two seconds and
|
|
|
114
114
|
|
|
115
115
|
Fresh top-level messages belong in the agent’s dedicated channel; responses to existing requests stay in their source thread. The watcher resolves a unique exact identity/name match (including `agent-alex` and `alex-agent`) and never falls back to general. Use `connect ... --channel <id>` for a differently named dedicated channel. If it is missing or ambiguous, ticket results remain recorded and channel delivery stays pending; the watcher does not invent a channel-creation tool.
|
|
116
116
|
|
|
117
|
-
Codex, OpenCode, Gemini CLI, and Qwen Code use Mastra ACP directly. OpenCode starts only for an accepted cycle and exits as soon as that cycle releases it, so the idle watcher does not retain an OpenCode child process. Other runtimes retain work sessions per ticket in a six-entry pool and reply sessions in an eight-entry pool; their idle sessions expire after five minutes, and active sessions cannot be evicted. Git snapshots are asynchronous, lightweight commands lazily load the watcher, and logs separate queue wait, preflight, first-event, and total runtime time. Projects reconcile with concurrency three and enqueue tickets before reading optional activity history. Coordination sessions can update the board without gaining repository access.
|
|
117
|
+
Codex, OpenCode, Gemini CLI, and Qwen Code use Mastra ACP directly. OpenCode starts only for an accepted cycle and exits as soon as that cycle releases it, so the idle watcher does not retain an OpenCode child process. Other runtimes retain work sessions per ticket in a six-entry pool and reply sessions in an eight-entry pool; their idle sessions expire after five minutes, and active sessions cannot be evicted. Git snapshots are asynchronous, lightweight commands lazily load the watcher, and logs separate queue wait, preflight, first-event, and total runtime time. Studio reads watcher liveness from the local PID lock instead of requiring journal heartbeats, and model changes arrive through filesystem notifications. Projects reconcile with concurrency three and enqueue tickets before reading optional activity history; later idle recovery reads activity only. Coordination sessions can update the board without gaining repository access.
|
|
118
118
|
|
|
119
119
|
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.
|
|
120
120
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openvisio-agent",
|
|
3
|
-
"version": "0.25.
|
|
3
|
+
"version": "0.25.7",
|
|
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": {
|
|
@@ -16,7 +16,7 @@ async function eventually(predicate, description) {
|
|
|
16
16
|
assert.fail(`Watcher did not reach: ${description}`)
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
export function fixture({ agent = 'codex', name, task = true, run, threadError = false, commentTools = [], onComment, selfProfile = self, listAgents, getTicket, listChannels, transportRecoveryDelays, activityReporter } = {}) {
|
|
19
|
+
export function fixture({ agent = 'codex', name, task = true, run, threadError = false, commentTools = [], onComment, selfProfile = self, listAgents, getTicket, listChannels, listActivity, 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' }
|
|
@@ -36,7 +36,7 @@ export function fixture({ agent = 'codex', name, task = true, run, threadError =
|
|
|
36
36
|
if (name === 'update_ticket') { Object.assign(ticket, args); return { ticket: { ...ticket } } }
|
|
37
37
|
if (name === 'list_channels' && listChannels) return { channels: await listChannels() }
|
|
38
38
|
if (name === 'list_channels') return { channels: [{ id: 2, name: 'alex', agent_id: 7 }, { id: 9, name: 'general' }] }
|
|
39
|
-
if (name === 'list_activity') return { activities: [] }
|
|
39
|
+
if (name === 'list_activity') return listActivity ? await listActivity(args) : { activities: [] }
|
|
40
40
|
if (name === 'list_message_thread') {
|
|
41
41
|
if (typeof threadError === 'function') await threadError()
|
|
42
42
|
else if (threadError) throw new Error('Thread service unavailable')
|
package/scripts/certify.mjs
CHANGED
|
@@ -24,6 +24,7 @@ run('diff whitespace check', 'git', ['diff', '--check'], repo)
|
|
|
24
24
|
const watcher = readFileSync(join(root, 'src', 'watch.mjs'), 'utf8')
|
|
25
25
|
const events = readFileSync(join(root, 'src', 'events.mjs'), 'utf8')
|
|
26
26
|
const memory = readFileSync(join(root, 'src', 'memory.mjs'), 'utf8')
|
|
27
|
+
const journal = readFileSync(join(root, 'src', 'agent-journal.mjs'), 'utf8')
|
|
27
28
|
const mcpHttp = readFileSync(join(root, 'src', 'mcp-http.mjs'), 'utf8')
|
|
28
29
|
const opencodeConfig = readFileSync(join(root, 'src', 'opencode-config.mjs'), 'utf8')
|
|
29
30
|
const codexConfig = readFileSync(join(root, 'src', 'codex-config.mjs'), 'utf8')
|
|
@@ -51,7 +52,9 @@ const assertions = [
|
|
|
51
52
|
['new follow-ups only deduplicate later replies', watcher.includes('repliesAfterSource(renderedAgentMessages') && watcher.includes('sourceMessageId: mid')],
|
|
52
53
|
['Mastra snapshots do not block the event loop', mastraHarness.includes('await execFileAsync') && !mastraHarness.includes('spawnSync')],
|
|
53
54
|
['OpenCode exits after work while the event watcher stays idle', runtimeProviders.includes("id: 'opencode'") && runtimeProviders.includes('idleSessionMs: 0') && watcher.includes('idleMs: runtimeIdleMs')],
|
|
54
|
-
['
|
|
55
|
+
['healthy backend watchers recover missed messages without polling projects or tasks while idle', watcher.includes('onConnect: () =>') && watcher.includes('void reconcileBacklog()') && watcher.includes('activityRecoveryTimer') && watcher.includes('replayRecentActivity(knownProjects)') && !watcher.includes('taskProbeTimer') && !watcher.includes('taskProbeStartTimer')],
|
|
56
|
+
['idle watchers use event notifications without journal or settings polling', watcher.includes('watchFs(stateDir') && !watcher.includes('modelSettingsTimer') && !journal.includes("emit('runtime.heartbeat'")],
|
|
57
|
+
['ticket pickup precedes optional activity reads', watcher.indexOf("await handleTaskSignal('task:assigned'", watcher.indexOf('const reconcileBacklog')) < watcher.indexOf('await replayRecentActivity(projects)', watcher.indexOf('const reconcileBacklog'))],
|
|
55
58
|
['task:assigned is handled', watcher.includes("k === 'task:assigned'")],
|
|
56
59
|
['task signals are verified with get_ticket', watcher.includes("callMcpTool('get_ticket'")],
|
|
57
60
|
['review and testing handoffs do not restart work', watcher.includes('taskIsAwaitingReview(task, reviewIds)') && watcher.includes('taskIsAwaitingReview(ticket, typeSets.review)')],
|
package/src/agent-journal.mjs
CHANGED
|
@@ -59,7 +59,7 @@ export function createAgentJournal({ stateDir, agent, secrets = [] }) {
|
|
|
59
59
|
const stem = `${slug}-${digest}`
|
|
60
60
|
const path = join(directory, stem + '.jsonl')
|
|
61
61
|
let sequence = 0, pendingBytes = 0, dropped = 0, written = 0, lastError = null
|
|
62
|
-
let timer,
|
|
62
|
+
let timer, closing = false, closePromise, writing = false
|
|
63
63
|
const pending = []
|
|
64
64
|
const ready = (async () => {
|
|
65
65
|
await mkdir(directory, { recursive: true, mode: 0o700 })
|
|
@@ -120,11 +120,8 @@ export function createAgentJournal({ stateDir, agent, secrets = [] }) {
|
|
|
120
120
|
catch (error) { lastError = error; return false }
|
|
121
121
|
}
|
|
122
122
|
emit('runtime.started', { status: 'running' })
|
|
123
|
-
heartbeat = setInterval(() => emit('runtime.heartbeat', { status: 'running', droppedEvents: dropped }), 15_000)
|
|
124
|
-
heartbeat.unref?.()
|
|
125
123
|
const close = () => {
|
|
126
124
|
if (closePromise) return closePromise
|
|
127
|
-
clearInterval(heartbeat)
|
|
128
125
|
emit('runtime.stopped', { status: 'stopped', droppedEvents: dropped })
|
|
129
126
|
closing = true
|
|
130
127
|
closePromise = (async () => {
|
package/src/studio-server.mjs
CHANGED
|
@@ -112,8 +112,13 @@ export async function readStudioSnapshot({ stateDir, now = Date.now, journalCach
|
|
|
112
112
|
const agentsById = new Map(configured.map((agent) => [agent.identifier, agent]))
|
|
113
113
|
for (const event of latest.values()) {
|
|
114
114
|
const age = now() - Date.parse(event.timestamp)
|
|
115
|
-
const online = age >= -5000 && age <= FRESH_MS && event.type !== 'runtime.stopped' && pidAlive(event.pid)
|
|
116
115
|
const existing = agentsById.get(event.agent.identifier)
|
|
116
|
+
// Configured watchers own a PID lock, so Studio can report their real process
|
|
117
|
+
// state without forcing an idle process to append heartbeat records forever.
|
|
118
|
+
// Freshness remains a compatibility fallback for older, unconfigured journals.
|
|
119
|
+
const configuredAlive = existing?.watcherAlive === true && existing.pid === event.pid
|
|
120
|
+
const recentUnconfigured = !existing?.configured && age >= -5000 && age <= FRESH_MS && pidAlive(event.pid)
|
|
121
|
+
const online = event.type !== 'runtime.stopped' && (configuredAlive || recentUnconfigured)
|
|
117
122
|
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 })
|
|
118
123
|
}
|
|
119
124
|
const agents = [...agentsById.values()]
|
package/src/watch.mjs
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
// The OpenVisio autonomy watcher — an event-driven
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// the shell installer so behaviour is identical — now shipped in the package rather
|
|
5
|
-
// than written to disk from a pasted heredoc.
|
|
1
|
+
// The OpenVisio autonomy watcher — an event-driven loop. It waits on the team
|
|
2
|
+
// event connection and starts a runtime only for accepted work. Relay mode keeps
|
|
3
|
+
// compatibility with older inbox polling connections.
|
|
6
4
|
|
|
7
5
|
import { spawn, spawnSync } from 'node:child_process'
|
|
8
6
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
9
|
-
import { closeSync, writeFileSync, mkdirSync, mkdtempSync, rmSync, existsSync, openSync, readFileSync, readdirSync, unlinkSync } from 'node:fs'
|
|
7
|
+
import { closeSync, writeFileSync, mkdirSync, mkdtempSync, rmSync, existsSync, openSync, readFileSync, readdirSync, unlinkSync, watch as watchFs } from 'node:fs'
|
|
10
8
|
import { homedir, tmpdir } from 'node:os'
|
|
11
9
|
import { fileURLToPath } from 'node:url'
|
|
12
10
|
import { join, dirname } from 'node:path'
|
|
@@ -585,8 +583,8 @@ export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, m
|
|
|
585
583
|
|
|
586
584
|
// ── the backend WS loop ──────────────────────────────────────────────────────
|
|
587
585
|
// One WebSocket identity feeds independent serialized work/reply queues. Each
|
|
588
|
-
// accepted source keeps its own context; direct MCP reconciliation
|
|
589
|
-
//
|
|
586
|
+
// accepted source keeps its own context; direct MCP reconciliation runs once
|
|
587
|
+
// when the event connection starts or reconnects, then the watcher stays idle.
|
|
590
588
|
function loopBackendWs(options) {
|
|
591
589
|
const watcher = createBackendWatcher(options)
|
|
592
590
|
const stop = () => void watcher.close().then(() => process.exit(0))
|
|
@@ -900,6 +898,8 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
900
898
|
// hoping poll_inbox re-surfaces the same item. Each queue entry retains that
|
|
901
899
|
// source independently through work, continuation, and delivery.
|
|
902
900
|
let backlogProbeBusy = false
|
|
901
|
+
let activityProbeBusy = false
|
|
902
|
+
let knownProjects = []
|
|
903
903
|
let selfAgentId = null
|
|
904
904
|
let selfOrganizationId = null
|
|
905
905
|
let identityContext = memory.get?.(`identity:${identifier}`)?.meta?.context || agentProfileContext({}, identifier)
|
|
@@ -1426,6 +1426,87 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1426
1426
|
return publishBlocker({ prompt, taskRef, delivery, notice, ticketNotice, pause: true })
|
|
1427
1427
|
}
|
|
1428
1428
|
|
|
1429
|
+
const replayRecentActivity = async (projects) => {
|
|
1430
|
+
const mentionActivity = []
|
|
1431
|
+
let activityReplayTouched = false
|
|
1432
|
+
await ensureSelfAgent()
|
|
1433
|
+
const mentionNeedles = [...selfAliases].map((alias) => '@' + alias)
|
|
1434
|
+
await mapConcurrent(projects, 3, async (project) => {
|
|
1435
|
+
const activityData = await callMcpTool('list_activity', { project_id: project.id }).then(toolData).catch((error) => {
|
|
1436
|
+
log('activity reconciliation unavailable: ' + (error?.message || error))
|
|
1437
|
+
return {}
|
|
1438
|
+
})
|
|
1439
|
+
const activities = Array.isArray(activityData.activities) ? activityData.activities : Array.isArray(activityData.activity) ? activityData.activity : []
|
|
1440
|
+
for (const item of activities) {
|
|
1441
|
+
const text = JSON.stringify(item)
|
|
1442
|
+
const activityKey = String(project.id) + ':' + String(item.id ?? item.message_id ?? item.messageId ?? text.slice(0, 500))
|
|
1443
|
+
const data = item?.data && typeof item.data === 'object' ? item.data : null
|
|
1444
|
+
const activityMessage = item?.message && typeof item.message === 'object'
|
|
1445
|
+
? item.message
|
|
1446
|
+
: data?.message && typeof data.message === 'object' ? data.message : item
|
|
1447
|
+
const activityText = String(activityMessage?.content ?? activityMessage?.body ?? activityMessage?.text ?? '').toLowerCase()
|
|
1448
|
+
const activityChannelId = item?.channel_id ?? item?.channelId ?? data?.channel_id ?? data?.channelId
|
|
1449
|
+
const activityMessageId = activityMessage?.id ?? activityMessage?.message_id ?? activityMessage?.messageId
|
|
1450
|
+
const activityParentId = messageParentId(activityMessage)
|
|
1451
|
+
const activityThreadRoot = activityParentId != null ? activityParentId : activityMessageId
|
|
1452
|
+
const activityControlKey = threadControlKey(activityChannelId, activityThreadRoot)
|
|
1453
|
+
const ownedActivity = !!activityControlKey && memory.has(activityControlKey, 'active')
|
|
1454
|
+
// Search only the source message, never the whole activity envelope: an
|
|
1455
|
+
// old tagged thread root can otherwise make another agent's new reply
|
|
1456
|
+
// look addressed to this watcher. Explicit mentions and human follow-ups
|
|
1457
|
+
// in a persistently owned thread both enter the guarded per-message path.
|
|
1458
|
+
const addressedActivity = mentionNeedles.some((needle) => activityText.includes(needle)) || ownedActivity || activityParentId != null
|
|
1459
|
+
if (!seenActivities.has(activityKey) && addressedActivity && /message|mention|channel/i.test(text)) {
|
|
1460
|
+
seenActivities.add(activityKey); trimSeen(seenActivities); activityReplayTouched = true
|
|
1461
|
+
// The same logical mention may already have arrived over WebSocket.
|
|
1462
|
+
// Share the id/signature guard instead of starting a second model turn.
|
|
1463
|
+
// Recover the former pre-routing acknowledgement bug exactly once
|
|
1464
|
+
// when an owned-thread source has no received ledger entry.
|
|
1465
|
+
if (markMentionHandled(activityMessage, activityChannelId)) {
|
|
1466
|
+
const activityMentionKeys = mentionDedupeKeys(activityMessage, activityChannelId)
|
|
1467
|
+
const activitySourceKey = `mention:${activityChannelId ?? '?'}:${activityMentionKeys.idKey || activityMentionKeys.signatureKey || activityThreadRoot}`
|
|
1468
|
+
if (memory.has(activitySourceKey, 'received')) continue
|
|
1469
|
+
}
|
|
1470
|
+
mentionActivity.push({
|
|
1471
|
+
projectId: project.id,
|
|
1472
|
+
project: project.name,
|
|
1473
|
+
channelId: activityChannelId,
|
|
1474
|
+
message: activityMessage,
|
|
1475
|
+
activity: item, activityKey,
|
|
1476
|
+
})
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
})
|
|
1480
|
+
if (activityReplayTouched) persistReplay()
|
|
1481
|
+
if (!mentionActivity.length) return
|
|
1482
|
+
log('activity recovery found ' + mentionActivity.length + ' mention(s) -> guarded reply cycle(s)')
|
|
1483
|
+
// Replay each real message through the exact same delivery path as a live
|
|
1484
|
+
// WebSocket mention. This preserves thread ids and lets postMessageOnce
|
|
1485
|
+
// consult the rendered thread before any reply is emitted.
|
|
1486
|
+
for (const mention of mentionActivity) {
|
|
1487
|
+
void onEvent('agent:mention', {
|
|
1488
|
+
project_id: mention.projectId,
|
|
1489
|
+
channel_id: mention.channelId,
|
|
1490
|
+
message: mention.message,
|
|
1491
|
+
_mentionAlreadyMarked: true,
|
|
1492
|
+
})?.then((result) => {
|
|
1493
|
+
if (result?.status === 'retry') { seenActivities.delete(mention.activityKey); persistReplay() }
|
|
1494
|
+
})
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
// The backend can occasionally omit a targeted WebSocket message. Keep a
|
|
1499
|
+
// cached project list from connection recovery and check only recent activity
|
|
1500
|
+
// while idle. This does not scan projects, boards, task types, or tickets and
|
|
1501
|
+
// does not start a provider process unless a new addressed message is found.
|
|
1502
|
+
const reconcileActivity = async () => {
|
|
1503
|
+
if (stopping || !mcpUrl || backlogProbeBusy || activityProbeBusy || !knownProjects.length) return
|
|
1504
|
+
activityProbeBusy = true
|
|
1505
|
+
try { await replayRecentActivity(knownProjects) }
|
|
1506
|
+
catch (e) { log('activity recovery failed: ' + (e?.message || e)) }
|
|
1507
|
+
finally { activityProbeBusy = false }
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1429
1510
|
// Reconcile everything that may have arrived while disconnected. This spends no
|
|
1430
1511
|
// model tokens unless the tools actually report pending work.
|
|
1431
1512
|
const reconcileBacklog = async () => {
|
|
@@ -1435,8 +1516,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1435
1516
|
try {
|
|
1436
1517
|
const projectsData = toolData(await callMcpTool('list_projects'), 'list_projects')
|
|
1437
1518
|
const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
|
|
1438
|
-
|
|
1439
|
-
let activityReplayTouched = false
|
|
1519
|
+
knownProjects = projects.map((project) => ({ id: project.id, name: project.name }))
|
|
1440
1520
|
await mapConcurrent(projects, 3, async (project) => {
|
|
1441
1521
|
try {
|
|
1442
1522
|
const assigned = []
|
|
@@ -1486,71 +1566,9 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1486
1566
|
seenTasks.delete(key)
|
|
1487
1567
|
await handleTaskSignal('task:assigned', { task: { id: next.id, project_id: next.projectId } })
|
|
1488
1568
|
}
|
|
1489
|
-
const activityData = await callMcpTool('list_activity', { project_id: project.id }).then(toolData).catch((error) => {
|
|
1490
|
-
log('activity reconciliation unavailable: ' + (error?.message || error))
|
|
1491
|
-
return {}
|
|
1492
|
-
})
|
|
1493
|
-
const activities = Array.isArray(activityData.activities) ? activityData.activities : Array.isArray(activityData.activity) ? activityData.activity : []
|
|
1494
|
-
await ensureSelfAgent()
|
|
1495
|
-
const mentionNeedles = [...selfAliases].map((alias) => '@' + alias)
|
|
1496
|
-
for (const item of activities) {
|
|
1497
|
-
const text = JSON.stringify(item)
|
|
1498
|
-
const activityKey = String(project.id) + ':' + String(item.id ?? item.message_id ?? item.messageId ?? text.slice(0, 500))
|
|
1499
|
-
const data = item?.data && typeof item.data === 'object' ? item.data : null
|
|
1500
|
-
const activityMessage = item?.message && typeof item.message === 'object'
|
|
1501
|
-
? item.message
|
|
1502
|
-
: data?.message && typeof data.message === 'object' ? data.message : item
|
|
1503
|
-
const activityText = String(activityMessage?.content ?? activityMessage?.body ?? activityMessage?.text ?? '').toLowerCase()
|
|
1504
|
-
const activityChannelId = item?.channel_id ?? item?.channelId ?? data?.channel_id ?? data?.channelId
|
|
1505
|
-
const activityMessageId = activityMessage?.id ?? activityMessage?.message_id ?? activityMessage?.messageId
|
|
1506
|
-
const activityParentId = messageParentId(activityMessage)
|
|
1507
|
-
const activityThreadRoot = activityParentId != null ? activityParentId : activityMessageId
|
|
1508
|
-
const activityControlKey = threadControlKey(activityChannelId, activityThreadRoot)
|
|
1509
|
-
const ownedActivity = !!activityControlKey && memory.has(activityControlKey, 'active')
|
|
1510
|
-
// Search only the source message, never the whole activity envelope: an
|
|
1511
|
-
// old tagged thread root can otherwise make another agent's new reply
|
|
1512
|
-
// look addressed to this watcher. Explicit mentions and human follow-ups
|
|
1513
|
-
// in a persistently owned thread both enter the guarded per-message path.
|
|
1514
|
-
const addressedActivity = mentionNeedles.some((needle) => activityText.includes(needle)) || ownedActivity || activityParentId != null
|
|
1515
|
-
if (!seenActivities.has(activityKey) && addressedActivity && /message|mention|channel/i.test(text)) {
|
|
1516
|
-
seenActivities.add(activityKey); trimSeen(seenActivities); activityReplayTouched = true
|
|
1517
|
-
// The same logical mention may already have arrived over WebSocket.
|
|
1518
|
-
// Share the id/signature guard instead of starting a second model turn.
|
|
1519
|
-
// Recover the former pre-routing acknowledgement bug exactly once
|
|
1520
|
-
// when an owned-thread source has no received ledger entry.
|
|
1521
|
-
if (markMentionHandled(activityMessage, activityChannelId)) {
|
|
1522
|
-
const activityMentionKeys = mentionDedupeKeys(activityMessage, activityChannelId)
|
|
1523
|
-
const activitySourceKey = `mention:${activityChannelId ?? '?'}:${activityMentionKeys.idKey || activityMentionKeys.signatureKey || activityThreadRoot}`
|
|
1524
|
-
if (memory.has(activitySourceKey, 'received')) continue
|
|
1525
|
-
}
|
|
1526
|
-
mentionActivity.push({
|
|
1527
|
-
projectId: project.id,
|
|
1528
|
-
project: project.name,
|
|
1529
|
-
channelId: activityChannelId,
|
|
1530
|
-
message: activityMessage,
|
|
1531
|
-
activity: item, activityKey,
|
|
1532
|
-
})
|
|
1533
|
-
}
|
|
1534
|
-
}
|
|
1535
1569
|
} catch (error) { log('Project reconciliation failed: ' + (error?.message || error)) }
|
|
1536
1570
|
})
|
|
1537
|
-
|
|
1538
|
-
if (mentionActivity.length) {
|
|
1539
|
-
log('backlog reconciliation found ' + mentionActivity.length + ' mention(s) -> guarded reply cycle(s)')
|
|
1540
|
-
// Replay each real message through the exact same delivery path as a live
|
|
1541
|
-
// WebSocket mention. This preserves thread ids and lets postMessageOnce
|
|
1542
|
-
// consult the rendered thread before any reply is emitted.
|
|
1543
|
-
for (const mention of mentionActivity) {
|
|
1544
|
-
void onEvent('agent:mention', {
|
|
1545
|
-
project_id: mention.projectId,
|
|
1546
|
-
channel_id: mention.channelId,
|
|
1547
|
-
message: mention.message,
|
|
1548
|
-
_mentionAlreadyMarked: true,
|
|
1549
|
-
})?.then((result) => {
|
|
1550
|
-
if (result?.status === 'retry') { seenActivities.delete(mention.activityKey); persistReplay() }
|
|
1551
|
-
})
|
|
1552
|
-
}
|
|
1553
|
-
}
|
|
1571
|
+
await replayRecentActivity(projects)
|
|
1554
1572
|
} catch (e) {
|
|
1555
1573
|
log('backlog reconciliation failed: ' + (e && e.message ? e.message : e))
|
|
1556
1574
|
} finally { backlogProbeBusy = false }
|
|
@@ -2191,21 +2209,22 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2191
2209
|
}
|
|
2192
2210
|
const deliveryTimer = autoStart ? setInterval(() => void flushPendingReplies(), 30_000) : null
|
|
2193
2211
|
deliveryTimer?.unref?.()
|
|
2212
|
+
const activityRecoveryTimer = autoStart ? setInterval(() => {
|
|
2213
|
+
if (!stopping) void trackOperation(reconcileActivity())
|
|
2214
|
+
}, 30_000) : null
|
|
2215
|
+
activityRecoveryTimer?.unref?.()
|
|
2194
2216
|
const authorizationTimer = autoStart ? setInterval(() => void resumeReadyAuthorizations(), 2_000) : null
|
|
2195
2217
|
authorizationTimer?.unref?.()
|
|
2196
|
-
|
|
2197
|
-
|
|
2218
|
+
let modelSettingsWatcher = null
|
|
2219
|
+
if (autoStart) {
|
|
2220
|
+
try {
|
|
2221
|
+
modelSettingsWatcher = watchFs(stateDir, { persistent: false }, (_event, filename) => {
|
|
2222
|
+
if (!filename || String(filename) === `${slug}.json`) refreshModelSettings()
|
|
2223
|
+
})
|
|
2224
|
+
} catch { /* every cycle still refreshes settings before choosing a model */ }
|
|
2225
|
+
}
|
|
2198
2226
|
emit('models.updated', { model: codeModel, chatModel: liteModel, modelSettingsRevision })
|
|
2199
|
-
|
|
2200
|
-
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 } : {}) })
|
|
2201
|
-
// The Studio reads a bounded journal tail. Re-state current queue ownership
|
|
2202
|
-
// so a long-running cycle stays visible after its start event rotates out.
|
|
2203
|
-
for (const { data, control } of liveCycles.values()) {
|
|
2204
|
-
if (!control.cancelled) emit('cycle.snapshot', { ...data, status: control.outcome, attempt: control.attempt || 1 })
|
|
2205
|
-
}
|
|
2206
|
-
}, 15_000) : null
|
|
2207
|
-
heartbeatTimer?.unref?.()
|
|
2208
|
-
let introTimer = null, taskProbeStartTimer = null, taskProbeTimer = null
|
|
2227
|
+
let introTimer = null
|
|
2209
2228
|
if (autoStart && (mcpConfig || mcpUrl)) {
|
|
2210
2229
|
// Workspace ethics: a one-time hello the FIRST time this agent ever connects.
|
|
2211
2230
|
const introMarker = join(stateDir, 'intro-' + slugify(identifier) + '.done')
|
|
@@ -2218,13 +2237,8 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2218
2237
|
}).catch((e) => log('guarded introduction failed: ' + (e?.message || e)))
|
|
2219
2238
|
}, 5000) // let the socket subscribe first
|
|
2220
2239
|
}
|
|
2221
|
-
//
|
|
2222
|
-
//
|
|
2223
|
-
// coding tasks to the full lane instead of letting Luna announce it cannot code.
|
|
2224
|
-
taskProbeStartTimer = setTimeout(() => void reconcileBacklog(), 3_000)
|
|
2225
|
-
// WebSocket delivery is primary. A short bounded reconciliation interval
|
|
2226
|
-
// keeps mentions responsive when a deployment omits targeted dispatch.
|
|
2227
|
-
taskProbeTimer = setInterval(() => void reconcileBacklog(), 30_000)
|
|
2240
|
+
// The WebSocket onConnect hook performs one recovery pass. Do not poll the
|
|
2241
|
+
// backend while a healthy event connection is already waiting for work.
|
|
2228
2242
|
} else {
|
|
2229
2243
|
log('no MCP config — skipping intro + daily sweep (agent has no tools to post/act)')
|
|
2230
2244
|
}
|
|
@@ -2238,11 +2252,9 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2238
2252
|
closePromise = (async () => {
|
|
2239
2253
|
clearInterval(authorizationTimer)
|
|
2240
2254
|
clearInterval(deliveryTimer)
|
|
2241
|
-
clearInterval(
|
|
2242
|
-
|
|
2255
|
+
clearInterval(activityRecoveryTimer)
|
|
2256
|
+
modelSettingsWatcher?.close()
|
|
2243
2257
|
if (introTimer) clearTimeout(introTimer)
|
|
2244
|
-
if (taskProbeStartTimer) clearTimeout(taskProbeStartTimer)
|
|
2245
|
-
if (taskProbeTimer) clearInterval(taskProbeTimer)
|
|
2246
2258
|
try { handle?.close() } catch { /* already disconnected */ }
|
|
2247
2259
|
for (const queue of Object.values(queues)) {
|
|
2248
2260
|
queue.cancel(() => true, (item) => { item.control.cancelled = true; item.control.wakeRecovery?.() })
|
|
@@ -2265,6 +2277,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2265
2277
|
}
|
|
2266
2278
|
return {
|
|
2267
2279
|
done, close, receive: onEvent, reconcile: () => stopping ? Promise.resolve() : trackOperation(reconcileBacklog()),
|
|
2280
|
+
recoverActivity: () => stopping ? Promise.resolve() : trackOperation(reconcileActivity()),
|
|
2268
2281
|
resumeAuthorizations: resumeReadyAuthorizations, retryDeliveries: flushPendingReplies,
|
|
2269
2282
|
get pending() { return queues.work.size + queues.reply.size + mentionIntake.size },
|
|
2270
2283
|
}
|
|
@@ -2305,12 +2318,9 @@ function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, mode
|
|
|
2305
2318
|
|
|
2306
2319
|
const seen = new Set()
|
|
2307
2320
|
let busy = false
|
|
2308
|
-
const heartbeatTimer = setInterval(() => emit('watcher.heartbeat', { status: 'running', active: busy ? 1 : 0, model, chatModel, workdir: workdir || '' }), 15_000)
|
|
2309
|
-
heartbeatTimer.unref?.()
|
|
2310
2321
|
const close = () => {
|
|
2311
2322
|
if (closePromise) return closePromise
|
|
2312
2323
|
stopping = true
|
|
2313
|
-
clearInterval(heartbeatTimer)
|
|
2314
2324
|
clearTimeout(introTimer)
|
|
2315
2325
|
clearInterval(sweepTimer)
|
|
2316
2326
|
closePromise = (async () => {
|
package/studio/app.mjs
CHANGED
|
@@ -393,7 +393,7 @@ function initializeStudio() {
|
|
|
393
393
|
$('open-model-settings').disabled = !agent.settingsEditable;
|
|
394
394
|
$('open-agent-removal').disabled = !agent.removable;
|
|
395
395
|
const watcherRunning = agent.status === 'online' || agent.watcherAlive === true;
|
|
396
|
-
$('toggle-watcher').textContent = watcherRunning ? 'Stop watcher' : 'Start watcher';
|
|
396
|
+
$('toggle-watcher').querySelector('span').textContent = watcherRunning ? 'Stop watcher' : 'Start watcher';
|
|
397
397
|
$('toggle-watcher').dataset.action = watcherRunning ? 'stop' : 'start';
|
|
398
398
|
$('toggle-watcher').disabled = watcherChanging || !agent.watcherControllable;
|
|
399
399
|
$('restart-watcher').disabled = watcherChanging || !agent.watcherControllable;
|
package/studio/guide.html
CHANGED
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
</section>
|
|
52
52
|
<section id="remove">
|
|
53
53
|
<h2>Remove a local agent</h2>
|
|
54
|
-
<p>Select an agent, choose <strong>Remove
|
|
54
|
+
<p>Select an agent, choose <strong>Remove agent</strong>, review the local data, and type its local name to confirm. Studio stops its watcher and background service, then removes its local connection credentials, logs, memory, and cached context. This cannot be undone.</p>
|
|
55
55
|
<p>Repositories, branches, shared model-provider logins, and the team’s agent profile stay in place. Context shared with another local connection is retained. Removal is available on macOS and Linux for the default local agent storage; custom state directories and Windows require manual management.</p>
|
|
56
56
|
</section>
|
|
57
57
|
<section id="watchers">
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
<li>Select the agent in Studio and choose <strong>Start watcher</strong>. Studio installs its background watcher and starts it with the current package and saved model settings.</li>
|
|
63
63
|
<li>Return to Studio. Your agent should appear in the sidebar. Select its name to see its activity.</li>
|
|
64
64
|
</ol>
|
|
65
|
-
<p>Use <strong>Stop watcher</strong> to take an agent offline and <strong>Restart watcher</strong> after changing its runtime installation or troubleshooting a connection. Restarting preserves its configuration and model choices.</p>
|
|
65
|
+
<p>Use <strong>Stop watcher</strong> to take an agent offline and <strong>Restart watcher</strong> after changing its runtime installation or troubleshooting a connection. Restarting preserves its configuration and model choices. While connected and idle, the watcher waits for WebSocket or local settings events without starting a coding runtime or polling project activity.</p>
|
|
66
66
|
<p>Watcher controls are available for backend agents in the default local agent storage on macOS and Linux. Their earlier activity cannot be reconstructed.</p>
|
|
67
67
|
</section>
|
|
68
68
|
<section id="find-work">
|
package/studio/index.html
CHANGED
|
@@ -34,6 +34,10 @@
|
|
|
34
34
|
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></symbol>
|
|
35
35
|
<symbol id="i-close" viewBox="0 0 24 24"><path d="m6 6 12 12M6 18 18 6"/></symbol>
|
|
36
36
|
<symbol id="i-copy" viewBox="0 0 24 24"><rect x="8" y="8" width="12" height="13" rx="2"/><path d="M16 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h3"/></symbol>
|
|
37
|
+
<symbol id="i-power" viewBox="0 0 24 24"><path d="M12 3v9"/><path d="M7.1 5.8a8 8 0 1 0 9.8 0"/></symbol>
|
|
38
|
+
<symbol id="i-refresh" viewBox="0 0 24 24"><path d="M20 7v5h-5"/><path d="M4 17v-5h5"/><path d="M6.1 8a7 7 0 0 1 11.7-1L20 12M4 12l2.2 5a7 7 0 0 0 11.7-1"/></symbol>
|
|
39
|
+
<symbol id="i-sliders" viewBox="0 0 24 24"><path d="M4 6h10M18 6h2M4 12h2m4 0h10M4 18h8m4 0h4"/><circle cx="16" cy="6" r="2"/><circle cx="8" cy="12" r="2"/><circle cx="14" cy="18" r="2"/></symbol>
|
|
40
|
+
<symbol id="i-user-minus" viewBox="0 0 24 24"><circle cx="9" cy="8" r="4"/><path d="M2 21a7 7 0 0 1 14 0M17 11h6"/></symbol>
|
|
37
41
|
</defs></svg>
|
|
38
42
|
<a class="skip-link" href="#workspace">Skip to activity</a>
|
|
39
43
|
<div class="app-shell">
|
|
@@ -61,7 +65,7 @@
|
|
|
61
65
|
<div id="notice" class="notice" role="status" hidden></div>
|
|
62
66
|
<section id="model-settings-summary" class="model-settings-summary" aria-label="Agent model settings" hidden>
|
|
63
67
|
<div><h2 id="model-settings-agent">Model settings</h2><p id="model-settings-state" role="status"></p></div>
|
|
64
|
-
<div class="header-actions"><button id="toggle-watcher" class="button" type="button">Start watcher</button><button id="restart-watcher" class="button" type="button">Restart watcher</button><button id="open-model-settings" class="button" type="button">Model settings</button><button id="open-agent-removal" class="button" type="button">Remove
|
|
68
|
+
<div class="header-actions model-settings-actions-row"><button id="toggle-watcher" class="button studio-action" type="button"><svg class="icon" aria-hidden="true"><use href="#i-power"/></svg><span>Start watcher</span></button><button id="restart-watcher" class="button studio-action restart-action" type="button"><svg class="icon" aria-hidden="true"><use href="#i-refresh"/></svg><span>Restart watcher</span></button><button id="open-model-settings" class="button studio-action settings-action" type="button"><svg class="icon" aria-hidden="true"><use href="#i-sliders"/></svg><span>Model settings</span></button><button id="open-agent-removal" class="button studio-action remove-action" type="button"><svg class="icon" aria-hidden="true"><use href="#i-user-minus"/></svg><span>Remove agent</span></button></div>
|
|
65
69
|
</section>
|
|
66
70
|
<dialog id="model-settings-dialog" aria-labelledby="model-settings-title">
|
|
67
71
|
<form id="model-settings-form" class="model-settings-form">
|
package/studio/style.css
CHANGED
|
@@ -360,6 +360,13 @@
|
|
|
360
360
|
.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
361
|
.model-settings-summary h2 { margin: 0 0 6px; font-size: 13px; font-weight: 500; }
|
|
362
362
|
.model-settings-summary p { margin: 0; color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
|
|
363
|
+
.model-settings-actions-row { gap: 8px; }
|
|
364
|
+
.studio-action .icon { transition: transform .16s ease-out, color .16s ease-out; }
|
|
365
|
+
.studio-action:hover .icon { transform: translateY(-1px); }
|
|
366
|
+
#toggle-watcher[data-action="start"] .icon { color: var(--success); }
|
|
367
|
+
#toggle-watcher[data-action="stop"] .icon, .remove-action .icon { color: var(--destructive); }
|
|
368
|
+
.restart-action .icon { color: var(--orange); }
|
|
369
|
+
.settings-action .icon { color: var(--purple); }
|
|
363
370
|
#model-settings-dialog, #agent-removal-dialog { width: min(500px, calc(100vw - 32px)); max-height: calc(100dvh - 32px); margin: auto; padding: 24px; overflow-y: auto; border: 1px solid var(--border); border-radius: 24px; color: var(--ink); background: var(--surface, white); box-shadow: 0 24px 80px #0002; }
|
|
364
371
|
#model-settings-dialog::backdrop, #agent-removal-dialog::backdrop { background: #0005; }
|
|
365
372
|
.model-settings-form { display: grid; gap: 12px; }
|