openvisio-agent 0.19.11 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -4
- package/USER_GUIDE.md +174 -0
- package/bin/cli.mjs +9 -0
- package/package.json +7 -3
- package/scenarios/index.mjs +16 -0
- package/scenarios/routing.scenarios.mjs +348 -0
- package/scenarios/runtime.scenarios.mjs +451 -0
- package/scenarios/transport.scenarios.mjs +407 -0
- package/scenarios/workspace.scenarios.mjs +324 -0
- package/scripts/certify.mjs +12 -8
- package/scripts/run-scenarios.mjs +32 -0
- package/src/agent-journal.mjs +138 -0
- package/src/assignment-routing.mjs +19 -7
- package/src/authorization-resume.mjs +1 -1
- package/src/channel-routing.mjs +15 -8
- package/src/codex-mcp-proxy.mjs +63 -18
- package/src/concurrency.mjs +5 -3
- package/src/cycle-queue.mjs +16 -3
- package/src/events.mjs +91 -39
- package/src/mastra-harness.mjs +128 -22
- package/src/mcp-http.mjs +72 -19
- package/src/memory.mjs +16 -3
- package/src/pr-push.mjs +21 -8
- package/src/runner-pool.mjs +14 -3
- package/src/studio-cli.mjs +60 -0
- package/src/studio-server.mjs +224 -0
- package/src/task-types.mjs +3 -2
- package/src/thread-context.mjs +62 -0
- package/src/watch.mjs +386 -114
- package/src/ws.mjs +20 -1
- package/studio/app.mjs +658 -0
- package/studio/guide.html +122 -0
- package/studio/index.html +61 -0
- package/studio/style.css +223 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import { OV_DIR } from './lib.mjs'
|
|
4
|
+
|
|
5
|
+
export function studioOptions({ flags = {}, positional = [] } = {}) {
|
|
6
|
+
const supported = new Set(['port', 'no-open', 'demo', 'state-dir'])
|
|
7
|
+
if (positional.length) throw new Error('studio accepts options only. Use openvisio-agent studio --help.')
|
|
8
|
+
for (const key of Object.keys(flags)) if (!supported.has(key)) throw new Error(`Unknown studio option --${key}.`)
|
|
9
|
+
for (const key of ['demo', 'no-open']) if (flags[key] != null && flags[key] !== true) throw new Error(`--${key} does not accept a value.`)
|
|
10
|
+
const rawPort = flags.port ?? 4317
|
|
11
|
+
if (!['string', 'number'].includes(typeof rawPort) || !/^\d+$/.test(String(rawPort)) || !Number.isSafeInteger(Number(rawPort)) || Number(rawPort) > 65535) throw new Error('--port must be a number between 0 and 65535 (0 selects an available port).')
|
|
12
|
+
if (flags['state-dir'] != null && (typeof flags['state-dir'] !== 'string' || !flags['state-dir'].trim())) throw new Error('--state-dir requires a directory path.')
|
|
13
|
+
return { host: '127.0.0.1', port: Number(rawPort), stateDir: flags['state-dir'] ? resolve(flags['state-dir']) : OV_DIR, demo: !!flags.demo, openBrowser: !flags['no-open'] }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function openStudioBrowser(url, { platform = process.platform, spawnProcess = spawn } = {}) {
|
|
17
|
+
const parsed = new URL(url)
|
|
18
|
+
if (parsed.protocol !== 'http:' || parsed.hostname !== '127.0.0.1') return Promise.resolve(false)
|
|
19
|
+
const command = platform === 'darwin' ? 'open' : platform === 'win32' ? 'rundll32' : 'xdg-open'
|
|
20
|
+
const args = platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url]
|
|
21
|
+
return new Promise((resolveResult) => {
|
|
22
|
+
let child
|
|
23
|
+
try { child = spawnProcess(command, args, { stdio: 'ignore', detached: false, shell: false, windowsHide: true }) }
|
|
24
|
+
catch { resolveResult(false); return }
|
|
25
|
+
let settled = false
|
|
26
|
+
const done = (value) => { if (!settled) { settled = true; clearTimeout(timer); resolveResult(value) } }
|
|
27
|
+
const timer = setTimeout(() => { try { child.kill() } catch {} done(false) }, 5000)
|
|
28
|
+
timer.unref?.()
|
|
29
|
+
child.once('error', () => done(false))
|
|
30
|
+
child.once('exit', (code) => done(code === 0))
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function runStudio(input, { startServer, openBrowser = openStudioBrowser, log = console.log, signals = process } = {}) {
|
|
35
|
+
const options = studioOptions(input)
|
|
36
|
+
const start = startServer || (await import('./studio-server.mjs')).startStudioServer
|
|
37
|
+
let server
|
|
38
|
+
try { server = await start(options) }
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (error?.code === 'EADDRINUSE') throw new Error(`Port ${options.port} is already in use. Open the existing viewer or run openvisio-agent studio --port 0.`)
|
|
41
|
+
throw error
|
|
42
|
+
}
|
|
43
|
+
log(`Agent studio: ${server.url}`)
|
|
44
|
+
if (options.demo) log('Demo mode shows simulated activity. It does not start agents or model calls.')
|
|
45
|
+
else log('Showing local watcher activity. Updated watchers record new cycles automatically.')
|
|
46
|
+
log('Press Ctrl+C to close the viewer. Agents keep running.')
|
|
47
|
+
let closing
|
|
48
|
+
const close = () => {
|
|
49
|
+
if (closing) return closing
|
|
50
|
+
signals.removeListener('SIGINT', close)
|
|
51
|
+
signals.removeListener('SIGTERM', close)
|
|
52
|
+
closing = Promise.resolve().then(() => server.close())
|
|
53
|
+
closing.catch((error) => { log(`Viewer shutdown failed: ${error?.message || error}`); process.exitCode = 1 })
|
|
54
|
+
return closing
|
|
55
|
+
}
|
|
56
|
+
signals.once('SIGINT', close)
|
|
57
|
+
signals.once('SIGTERM', close)
|
|
58
|
+
if (options.openBrowser && !(await openBrowser(server.url))) log(`Open ${server.url} in your browser.`)
|
|
59
|
+
return { ...server, close }
|
|
60
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { constants } from 'node:fs'
|
|
3
|
+
import { lstat, open, readdir, readFile, realpath } from 'node:fs/promises'
|
|
4
|
+
import { createServer } from 'node:http'
|
|
5
|
+
import { dirname, join, relative, resolve } from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { isPrivateThoughtEvent, sanitizeJournalData } from './agent-journal.mjs'
|
|
8
|
+
|
|
9
|
+
const MAX_EVENTS = 500, MAX_FILES = 32, MAX_FILE_BYTES = 256 * 1024, MAX_TOTAL_BYTES = 4 * 1024 * 1024
|
|
10
|
+
const FRESH_MS = 45_000
|
|
11
|
+
const DEFAULT_ASSETS = fileURLToPath(new URL('../studio/', import.meta.url))
|
|
12
|
+
const pidAlive = (pid) => {
|
|
13
|
+
if (!Number.isSafeInteger(pid) || pid < 1) return false
|
|
14
|
+
try { process.kill(pid, 0); return true } catch (error) { return error.code === 'EPERM' }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function normalizeEvent(value) {
|
|
18
|
+
if (!value || value.schemaVersion !== 1 || typeof value.id !== 'string' || typeof value.runId !== 'string' || typeof value.agent?.identifier !== 'string' || !Number.isFinite(Date.parse(value.timestamp)) || typeof value.type !== 'string' || isPrivateThoughtEvent(value.type)) return null
|
|
19
|
+
return sanitizeJournalData(value)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function tailEvents(path, maxBytes) {
|
|
23
|
+
const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0))
|
|
24
|
+
try {
|
|
25
|
+
const entry = await handle.stat()
|
|
26
|
+
if (!entry.isFile()) return { events: [], bytes: 0 }
|
|
27
|
+
const count = Math.min(entry.size, maxBytes), offset = entry.size - count
|
|
28
|
+
const bytes = Buffer.alloc(count)
|
|
29
|
+
const { bytesRead } = await handle.read(bytes, 0, count, offset)
|
|
30
|
+
let text = bytes.subarray(0, bytesRead).toString('utf8')
|
|
31
|
+
if (offset) text = text.slice(text.indexOf('\n') + 1)
|
|
32
|
+
const lines = text.split('\n')
|
|
33
|
+
lines.pop() // A partial final line is retried on the next snapshot.
|
|
34
|
+
const events = []
|
|
35
|
+
for (const line of lines) {
|
|
36
|
+
if (Buffer.byteLength(line) > 32 * 1024) continue
|
|
37
|
+
try { const event = normalizeEvent(JSON.parse(line)); if (event) events.push(event) } catch { /* partial or corrupt record */ }
|
|
38
|
+
}
|
|
39
|
+
return { events, bytes: bytesRead }
|
|
40
|
+
} finally { await handle.close() }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function readSmallLocalFile(path, maxBytes = 64 * 1024) {
|
|
44
|
+
const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0))
|
|
45
|
+
try {
|
|
46
|
+
const entry = await handle.stat()
|
|
47
|
+
if (!entry.isFile() || entry.size > maxBytes) return null
|
|
48
|
+
return await handle.readFile('utf8')
|
|
49
|
+
} finally { await handle.close() }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function configuredAgents(stateDir) {
|
|
53
|
+
const entries = await readdir(stateDir, { withFileTypes: true }).catch(() => [])
|
|
54
|
+
const agents = []
|
|
55
|
+
// Only direct, conventionally named setup files are considered; never recurse
|
|
56
|
+
// into credential/config folders or expose their complete contents.
|
|
57
|
+
for (const entry of entries.filter((entry) => entry.isFile() && /^[a-z0-9][a-z0-9_-]{0,79}\.json$/i.test(entry.name)).slice(0, 64)) {
|
|
58
|
+
try {
|
|
59
|
+
const config = JSON.parse(await readSmallLocalFile(join(stateDir, entry.name)))
|
|
60
|
+
if (!config?.slug || entry.name !== `${config.slug}.json`) continue
|
|
61
|
+
const backend = config.mode === 'backend' && typeof config.identifier === 'string' && config.identifier && typeof config.apiKey === 'string'
|
|
62
|
+
const relay = typeof config.host === 'string' && typeof config.key === 'string'
|
|
63
|
+
if (!backend && !relay) continue
|
|
64
|
+
let pid = null, watcherAlive = null
|
|
65
|
+
const lock = await readSmallLocalFile(join(stateDir, `watch-${config.slug}.lock`), 64).catch(() => null)
|
|
66
|
+
if (/^\d+\s*$/.test(lock || '')) { pid = Number(lock.trim()); watcherAlive = pidAlive(pid) }
|
|
67
|
+
const identifier = backend ? config.identifier : config.slug
|
|
68
|
+
agents.push(sanitizeJournalData({ id: identifier, identifier, slug: config.slug, name: config.name || config.slug, provider: config.agent || 'claude', configured: true, runId: null, pid, watcherAlive, status: 'uninstrumented', lastSeen: null, lastEventType: null }))
|
|
69
|
+
} catch { /* unrelated, malformed or concurrently replaced setup file */ }
|
|
70
|
+
}
|
|
71
|
+
return agents
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function readStudioSnapshot({ stateDir, now = Date.now }) {
|
|
75
|
+
const warnings = [], directory = join(stateDir, 'observability')
|
|
76
|
+
let files = []
|
|
77
|
+
try {
|
|
78
|
+
const entry = await lstat(directory)
|
|
79
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error('Observability path is not a real directory')
|
|
80
|
+
const entries = await readdir(directory, { withFileTypes: true })
|
|
81
|
+
files = (await Promise.all(entries.filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl')).slice(0, 256).map(async (entry) => {
|
|
82
|
+
const path = join(directory, entry.name)
|
|
83
|
+
const metadata = await lstat(path).catch(() => null)
|
|
84
|
+
return metadata?.isFile() && !metadata.isSymbolicLink() ? { path, modified: metadata.mtimeMs } : null
|
|
85
|
+
}))).filter(Boolean).sort((a, b) => b.modified - a.modified).slice(0, MAX_FILES)
|
|
86
|
+
if (entries.length > MAX_FILES) warnings.push('The viewer is showing a bounded tail of the most recently updated agent journals.')
|
|
87
|
+
} catch (error) { if (error.code !== 'ENOENT') warnings.push('Agent journals are currently unavailable.') }
|
|
88
|
+
const collected = []
|
|
89
|
+
let remaining = MAX_TOTAL_BYTES
|
|
90
|
+
for (const file of files) {
|
|
91
|
+
if (remaining <= 0) break
|
|
92
|
+
try {
|
|
93
|
+
const result = await tailEvents(file.path, Math.min(MAX_FILE_BYTES, remaining))
|
|
94
|
+
remaining -= result.bytes
|
|
95
|
+
collected.push(...result.events)
|
|
96
|
+
} catch { /* rotation or removal raced the read; retry next snapshot */ }
|
|
97
|
+
}
|
|
98
|
+
const events = [...new Map(collected.map((event) => [event.id, event])).values()].sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp) || (a.runId === b.runId ? Number(a.sequence || 0) - Number(b.sequence || 0) : 0))
|
|
99
|
+
const latest = new Map()
|
|
100
|
+
for (const event of events) latest.set(event.agent.identifier, event)
|
|
101
|
+
const configured = await configuredAgents(stateDir)
|
|
102
|
+
const agentsById = new Map(configured.map((agent) => [agent.identifier, agent]))
|
|
103
|
+
for (const event of latest.values()) {
|
|
104
|
+
const age = now() - Date.parse(event.timestamp)
|
|
105
|
+
const online = age >= -5000 && age <= FRESH_MS && event.type !== 'runtime.stopped' && pidAlive(event.pid)
|
|
106
|
+
const existing = agentsById.get(event.agent.identifier)
|
|
107
|
+
agentsById.set(event.agent.identifier, { ...existing, id: event.agent.identifier, identifier: event.agent.identifier, slug: event.agent.slug || event.agent.identifier, name: existing?.name || event.agent.slug || event.agent.identifier, provider: event.agent.provider || 'unknown', runId: event.runId, pid: Number.isSafeInteger(event.pid) ? event.pid : null, watcherAlive: online, status: online ? 'online' : 'offline', lastSeen: event.timestamp, lastEventType: event.type })
|
|
108
|
+
}
|
|
109
|
+
const agents = [...agentsById.values()].sort((a, b) => a.name.localeCompare(b.name))
|
|
110
|
+
return { schemaVersion: 1, demo: false, generatedAt: new Date(now()).toISOString(), agents, events: events.slice(-MAX_EVENTS), limits: { maxEvents: MAX_EVENTS, maxFiles: MAX_FILES, maxTotalBytes: MAX_TOTAL_BYTES }, warnings }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function demoSnapshot(stamp) {
|
|
114
|
+
const events = []
|
|
115
|
+
const agents = ['codex', 'opencode', 'claude'].map((provider, index) => ({ id: `demo-${provider}`, identifier: `demo-${provider}`, slug: ['Alex', 'Robin', 'Sam'][index], name: ['Alex', 'Robin', 'Sam'][index], provider, runId: `demo-run-${provider}`, pid: null, status: 'demo', demo: true, lastSeen: new Date(stamp).toISOString(), lastEventType: 'output.final' }))
|
|
116
|
+
for (const [index, agent] of agents.entries()) {
|
|
117
|
+
const cycleId = `${agent.runId}:cycle-1`, common = { cycleId, kind: 'full', lane: 'work', model: ['gpt-5.6-sol', 'openai/gpt-5.6-sol', 'claude-sonnet'][index], workdir: '/demo/workspace/ticket-77', ticket: { projectId: 5, ticketId: 77, slug: 'DEMO-77' }, watcherPid: null }
|
|
118
|
+
const rows = [
|
|
119
|
+
['runtime.started', { status: 'simulated' }],
|
|
120
|
+
['cycle.queued', { ...common, status: 'queued' }],
|
|
121
|
+
['cycle.started', { ...common, status: 'running', queueMs: 24 }],
|
|
122
|
+
['process.started', { ...common, runtime: 'ACP', runtimePid: null, status: 'connecting' }],
|
|
123
|
+
['plan.updated', { ...common, entries: [{ content: 'Inspect the failing build', status: 'completed', priority: 'high' }, { content: 'Fix the module import and run tests', status: 'completed', priority: 'high' }, { content: 'Publish the verified result', status: 'completed', priority: 'normal' }] }],
|
|
124
|
+
['tool.started', { ...common, toolCallId: 'demo-read', name: 'read_file', title: 'Read package.json', status: 'running', kind: 'read', locations: [{ path: 'package.json' }] }],
|
|
125
|
+
['tool.finished', { ...common, toolCallId: 'demo-read', name: 'read_file', title: 'Read package.json', status: 'completed', kind: 'read', locations: [{ path: 'package.json' }] }],
|
|
126
|
+
['output.progress', { ...common, text: 'I found the import that prevented the build from completing.' }],
|
|
127
|
+
['tool.started', { ...common, toolCallId: 'demo-test', name: 'execute', title: 'Run the build and tests', status: 'running', kind: 'execute' }],
|
|
128
|
+
['tool.finished', { ...common, toolCallId: 'demo-test', name: 'execute', title: 'Run the build and tests', status: 'completed', kind: 'execute' }],
|
|
129
|
+
['output.final', { ...common, text: 'The module import is fixed. The build and 18 tests pass. This is simulated demonstration data.' }],
|
|
130
|
+
['cycle.finished', { ...common, status: 'completed', durationMs: 24_000 }],
|
|
131
|
+
['process.ready', { ...common, runtime: 'ACP', runtimePid: null, status: 'idle' }],
|
|
132
|
+
]
|
|
133
|
+
for (const [offset, [type, data]] of rows.entries()) events.push({ schemaVersion: 1, id: `${agent.runId}:${offset + 1}`, sequence: offset + 1, runId: agent.runId, timestamp: new Date(stamp - 30_000 + index * 1000 + offset * 1500).toISOString(), type, agent: { identifier: agent.identifier, slug: agent.slug, provider: agent.provider }, pid: null, demo: true, data })
|
|
134
|
+
}
|
|
135
|
+
return { schemaVersion: 1, demo: true, generatedAt: new Date(stamp).toISOString(), agents, events: events.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)), limits: { maxEvents: MAX_EVENTS }, warnings: ['Demo mode contains simulated agent activity. No model or task is running.'] }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function startStudioServer({ stateDir, host = '127.0.0.1', port = 4317, assetsDir = DEFAULT_ASSETS, demo = false }) {
|
|
139
|
+
if (!['127.0.0.1', '::1', 'localhost'].includes(host)) throw new Error('Agent Studio can bind only to a loopback address')
|
|
140
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Agent Studio port must be an integer from 0 to 65535')
|
|
141
|
+
if (!stateDir) throw new Error('Agent Studio requires a local state directory')
|
|
142
|
+
const bindHost = host === 'localhost' ? '127.0.0.1' : host
|
|
143
|
+
const root = await realpath(assetsDir)
|
|
144
|
+
const clients = new Set()
|
|
145
|
+
const demoState = demo ? demoSnapshot(Date.now()) : null
|
|
146
|
+
let current, pending, stopped = false, interval
|
|
147
|
+
const delivered = new WeakMap()
|
|
148
|
+
const fingerprint = (value) => createHash('sha256').update(JSON.stringify({ agents: value.agents, events: value.events, warnings: value.warnings })).digest('hex')
|
|
149
|
+
const snapshot = async () => {
|
|
150
|
+
if (pending) return pending
|
|
151
|
+
pending = (async () => {
|
|
152
|
+
current = demoState || await readStudioSnapshot({ stateDir })
|
|
153
|
+
return current
|
|
154
|
+
})().finally(() => { pending = null })
|
|
155
|
+
return pending
|
|
156
|
+
}
|
|
157
|
+
const headers = {
|
|
158
|
+
'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'referrer-policy': 'no-referrer',
|
|
159
|
+
'content-security-policy': "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
|
|
160
|
+
'x-frame-options': 'DENY', 'cross-origin-resource-policy': 'same-origin',
|
|
161
|
+
}
|
|
162
|
+
const sendSnapshot = (res, value) => {
|
|
163
|
+
if (res.writableNeedDrain) return
|
|
164
|
+
res.write(`event: snapshot\ndata: ${JSON.stringify(value)}\n\n`)
|
|
165
|
+
delivered.set(res, fingerprint(value))
|
|
166
|
+
if (res.writableLength > 8 * 1024 * 1024) { clients.delete(res); res.end() }
|
|
167
|
+
}
|
|
168
|
+
const server = createServer(async (req, res) => {
|
|
169
|
+
for (const [key, value] of Object.entries(headers)) res.setHeader(key, value)
|
|
170
|
+
try {
|
|
171
|
+
const boundPort = server.address().port
|
|
172
|
+
const allowedAuthorities = new Set([`127.0.0.1:${boundPort}`, `localhost:${boundPort}`, `[::1]:${boundPort}`])
|
|
173
|
+
if (!allowedAuthorities.has(String(req.headers.host || '').toLowerCase())) { res.writeHead(403); res.end('Invalid local host'); return }
|
|
174
|
+
if (req.headers.origin && ![...allowedAuthorities].some((authority) => req.headers.origin === `http://${authority}`)) { res.writeHead(403); res.end('Cross-origin access denied'); return }
|
|
175
|
+
if (req.headers['sec-fetch-site'] === 'cross-site') { res.writeHead(403); res.end('Cross-site access denied'); return }
|
|
176
|
+
if (!['GET', 'HEAD'].includes(req.method)) { res.writeHead(405, { allow: 'GET, HEAD' }); res.end('Read-only viewer'); return }
|
|
177
|
+
const path = new URL(req.url, `http://${req.headers.host}`).pathname
|
|
178
|
+
if (path === '/api/snapshot') { res.setHeader('content-type', 'application/json; charset=utf-8'); res.end(req.method === 'HEAD' ? undefined : JSON.stringify(await snapshot())); return }
|
|
179
|
+
if (path === '/api/events' && req.method === 'GET') {
|
|
180
|
+
if (clients.size >= 32) { res.writeHead(503); res.end('Too many local viewer connections'); return }
|
|
181
|
+
res.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8', connection: 'keep-alive' })
|
|
182
|
+
clients.add(res); req.on('close', () => clients.delete(res))
|
|
183
|
+
sendSnapshot(res, await snapshot())
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
const file = ({ '/': 'index.html', '/index.html': 'index.html', '/guide': 'guide.html', '/guide.html': 'guide.html', '/style.css': 'style.css', '/app.mjs': 'app.mjs' })[path]
|
|
187
|
+
if (!file) { res.writeHead(404); res.end('Not found'); return }
|
|
188
|
+
const target = await realpath(resolve(root, file))
|
|
189
|
+
const rel = relative(root, target)
|
|
190
|
+
if (rel.startsWith('..') || resolve(dirname(target)) !== root) { res.writeHead(403); res.end('Invalid asset'); return }
|
|
191
|
+
const body = await readFile(target)
|
|
192
|
+
res.setHeader('content-type', file.endsWith('.css') ? 'text/css; charset=utf-8' : file.endsWith('.mjs') ? 'text/javascript; charset=utf-8' : 'text/html; charset=utf-8')
|
|
193
|
+
res.end(req.method === 'HEAD' ? undefined : body)
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (!res.headersSent) res.writeHead(error.code === 'ENOENT' ? 404 : 500)
|
|
196
|
+
res.end('Local viewer request unavailable')
|
|
197
|
+
}
|
|
198
|
+
})
|
|
199
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, bindHost, resolve) }).catch((error) => {
|
|
200
|
+
if (error.code === 'EADDRINUSE') throw Object.assign(new Error(`Agent Studio port ${port} is already in use. Choose another port with --port.`), { code: error.code })
|
|
201
|
+
throw error
|
|
202
|
+
})
|
|
203
|
+
let ticks = 0
|
|
204
|
+
interval = setInterval(async () => {
|
|
205
|
+
if (stopped || !clients.size) return
|
|
206
|
+
try {
|
|
207
|
+
const value = await snapshot()
|
|
208
|
+
const next = fingerprint(value)
|
|
209
|
+
for (const res of clients) if (delivered.get(res) !== next) sendSnapshot(res, value)
|
|
210
|
+
if (++ticks % 30 === 0) for (const res of clients) if (!res.writableNeedDrain) res.write(': heartbeat\n\n')
|
|
211
|
+
} catch { /* keep serving the next readable snapshot */ }
|
|
212
|
+
}, 500)
|
|
213
|
+
interval.unref?.()
|
|
214
|
+
let closing
|
|
215
|
+
const close = () => {
|
|
216
|
+
if (closing) return closing
|
|
217
|
+
stopped = true; clearInterval(interval)
|
|
218
|
+
for (const res of clients) res.end()
|
|
219
|
+
clients.clear(); server.closeAllConnections()
|
|
220
|
+
closing = new Promise((resolve) => server.close(resolve))
|
|
221
|
+
return closing
|
|
222
|
+
}
|
|
223
|
+
return { url: `http://${bindHost === '::1' ? '[::1]' : bindHost}:${server.address().port}`, close }
|
|
224
|
+
}
|
package/src/task-types.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export function createTaskTypeLookup({ load, now = Date.now, ttlMs = 60_000 }) {
|
|
2
|
+
if (!Number.isFinite(ttlMs) || ttlMs < 0) throw new Error('Invalid task column cache lifetime')
|
|
2
3
|
const cache = new Map(), pending = new Map()
|
|
3
4
|
return async (projectId) => {
|
|
4
5
|
const key = String(projectId)
|
|
@@ -6,9 +7,9 @@ export function createTaskTypeLookup({ load, now = Date.now, ttlMs = 60_000 }) {
|
|
|
6
7
|
if (hit && hit.expiresAt > now()) return hit.value
|
|
7
8
|
if (pending.has(key)) return pending.get(key)
|
|
8
9
|
const request = Promise.resolve().then(() => load(projectId)).then((data) => {
|
|
9
|
-
const types = data
|
|
10
|
+
const types = data?.types ?? data?.task_types ?? data?.taskTypes
|
|
10
11
|
if (!Array.isArray(types)) throw new Error('Cannot verify task columns')
|
|
11
|
-
const matching = (pattern) => new Set(types.filter((type) => pattern.test(String(type.name || ''))).map((type) => Number(type.id)))
|
|
12
|
+
const matching = (pattern) => new Set(types.filter((type) => type && ['number', 'string'].includes(typeof type.id) && Number.isSafeInteger(Number(type.id)) && Number(type.id) > 0 && pattern.test(String(type.name || ''))).map((type) => Number(type.id)))
|
|
12
13
|
const value = {
|
|
13
14
|
done: matching(/\b(?:done|complete|completed|closed|cancelled|canceled|archived|resolved)\b/i),
|
|
14
15
|
review: matching(/\b(?:review|test|testing|qa|quality\s+assurance|verification|approval)\b/i),
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { messageParentId, messageSenderIsAgent, renderedAgentMessages } from './events.mjs'
|
|
2
|
+
|
|
3
|
+
// An agent's own root post establishes a conversation, even if nobody tagged
|
|
4
|
+
// the agent before replying. Verify the exact root, never mere participation.
|
|
5
|
+
export function ownThreadRoot(thread, rootId, identity) {
|
|
6
|
+
if (rootId == null || String(rootId).trim() === '') return null
|
|
7
|
+
return renderedAgentMessages(thread, identity).find((row) => String(row.id) === String(rootId) && row.parentId == null) || null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function verifiedOtherAuthor(row, identity) {
|
|
11
|
+
const sender = [row.sender, row.user, row.author, row.member].find((value) => value && typeof value === 'object') || {}
|
|
12
|
+
if (messageSenderIsAgent(row)) {
|
|
13
|
+
const agent = [row.senderAgent, row.sender_agent, row.agent, sender.agent].find((value) => value && typeof value === 'object') || sender
|
|
14
|
+
const agentId = agent.id ?? agent.agent_id ?? row.agent_id ?? row.sender_agent_id ?? row.senderAgentId
|
|
15
|
+
const knownId = (id) => messageParentId({ parent_id: id }) != null
|
|
16
|
+
if (knownId(identity?.id) && knownId(agentId)) return Number(agentId) !== Number(identity.id)
|
|
17
|
+
const identifier = agent.identifier ?? agent.slug ?? row.agent_identifier ?? row.sender_identifier
|
|
18
|
+
return !!identifier && !!identity?.identifier && String(identifier).toLowerCase() !== String(identity.identifier).toLowerCase()
|
|
19
|
+
}
|
|
20
|
+
const kind = String(sender.type ?? sender.kind ?? row.sender_type ?? row.author_type ?? '').toLowerCase()
|
|
21
|
+
return /^(?:human|user|member|person)$/.test(kind) || typeof sender.email === 'string' || messageParentId({ parent_id: row.sender_id ?? row.user_id ?? row.author_id }) != null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function containsRoot(value, rootId, identity, depth = 0) {
|
|
25
|
+
if (!value || depth > 6) return false
|
|
26
|
+
if (Array.isArray(value)) return value.some((row) => containsRoot(row, rootId, identity, depth + 1))
|
|
27
|
+
if (typeof value !== 'object') return false
|
|
28
|
+
if (String(value.message_id ?? value.messageId ?? value.id) === String(rootId) && messageParentId(value) == null && typeof (value.content ?? value.body ?? value.text) === 'string' && verifiedOtherAuthor(value, identity)) return true
|
|
29
|
+
return ['messages', 'replies', 'items', 'data', 'result', 'thread', 'message', 'root'].some((key) => containsRoot(value[key], rootId, identity, depth + 1))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createThreadContextResolver({ read, save, cancelled, load, identity, now = Date.now }) {
|
|
33
|
+
const pending = new Map()
|
|
34
|
+
const otherRoots = new Map()
|
|
35
|
+
return async (channelId, rootId) => {
|
|
36
|
+
if (channelId == null || rootId == null || cancelled(channelId, rootId)) return ''
|
|
37
|
+
const cached = read(channelId, rootId)
|
|
38
|
+
if (cached) return cached
|
|
39
|
+
const key = `${channelId}:${rootId}`
|
|
40
|
+
const currentIdentity = identity()
|
|
41
|
+
const identityKey = JSON.stringify([currentIdentity?.id, currentIdentity?.identifier, currentIdentity?.slug])
|
|
42
|
+
const previous = otherRoots.get(key)
|
|
43
|
+
if (previous?.identityKey === identityKey && previous.until > now()) return ''
|
|
44
|
+
if (!pending.has(key)) {
|
|
45
|
+
const request = Promise.resolve().then(() => load(channelId, rootId)).then((thread) => {
|
|
46
|
+
const root = ownThreadRoot(thread, rootId, identity())
|
|
47
|
+
// Organization fanout repeats unrelated human threads. Cache only an
|
|
48
|
+
// observed non-owned root, never an unavailable/incomplete API result.
|
|
49
|
+
if (!root && containsRoot(thread, rootId, identity())) {
|
|
50
|
+
otherRoots.delete(key)
|
|
51
|
+
otherRoots.set(key, { identityKey, until: now() + 60_000 })
|
|
52
|
+
while (otherRoots.size > 256) otherRoots.delete(otherRoots.keys().next().value)
|
|
53
|
+
}
|
|
54
|
+
if (!root || cancelled(channelId, rootId)) return ''
|
|
55
|
+
save(channelId, rootId, root.content)
|
|
56
|
+
return root.content
|
|
57
|
+
}).finally(() => pending.delete(key))
|
|
58
|
+
pending.set(key, request)
|
|
59
|
+
}
|
|
60
|
+
return pending.get(key)
|
|
61
|
+
}
|
|
62
|
+
}
|