fix cloud agents chat continuity and avatars

This commit is contained in:
Ryder Sun committed 2026-08-25 12:50:44 +08:00
1 parent da0108abb1
commit 7752489936
49 files changed
+3077 -122

No files matched your search

+12 -2
View File
@@ -339,7 +339,18 @@ class AgentRouter {
const explicitReadOnlyTargets = effectiveMode === 'explicit'
&& explicitTargets.length > 0
&& explicitTargets.every(kind => isCodeReviewAgentKind(kind))
const permissionMode = explicitReadOnlyTargets
const detected = new Map((Array.isArray(agents) ? agents : []).map(agent => [agent.kind, agent]))
const permissionTargets = effectiveMode === 'explicit' ? explicitTargets : approvedKinds
const targetWriteSupport = permissionTargets.map((kind) => (
agentRuntimeCapabilities(kind, {
agent: detected.get(kind),
attachmentSupport: this.attachmentSupport(kind),
}).permissionModes.includes('workspace-write')
))
const writePermissionCompatible = effectiveMode === 'explicit'
? targetWriteSupport.every(Boolean)
: targetWriteSupport.some(Boolean)
const permissionMode = explicitReadOnlyTargets || !writePermissionCompatible
? 'read-only'
: group.allowWrite === true ? 'workspace-write' : 'read-only'
const requirements = normalizeRoutingRequirements(input.routingRequirements, {
@@ -354,7 +365,6 @@ class AgentRouter {
const kind = explicitTargets.find(target => !approvedKinds.includes(target))
throw routingError('LOCAL_MESSAGE_TARGET_OUT_OF_GROUP', { kind })
}
const detected = new Map((Array.isArray(agents) ? agents : []).map(agent => [agent.kind, agent]))
const candidates = approvedKinds.map((kind) => evaluateCandidate(
detected.get(kind) || { kind, available: false },
requirements,
@@ -0,0 +1,276 @@
const fs = require('node:fs')
const http = require('node:http')
const os = require('node:os')
const path = require('node:path')
const { spawn, spawnSync } = require('node:child_process')
const requestedPort = Number(process.env.MELDWORK_AGENT_BRIDGE_PORT || 8765)
const PORT = Number.isInteger(requestedPort) && requestedPort >= 1024 && requestedPort <= 65535
? requestedPort
: 8765
const MAX_REQUEST_BYTES = 4 * 1024 * 1024
const MAX_RESPONSE_BYTES = 256 * 1024
const MAX_SKILLS = 64
const PUBLIC_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/
const AGENT_DEFINITIONS = Object.freeze([
{
id: 'codex', label: 'Codex',
description: 'Codex CLI exposed through an SSH-local Bridge.',
auth(command) { return command('codex', ['login', 'status']).status === 0 },
},
{
id: 'hermes', label: 'Hermes',
description: 'Hermes Agent exposed through an SSH-local Bridge.',
auth() { return true },
},
{
id: 'claude', label: 'Claude Code',
description: 'Claude Code exposed through an SSH-local Bridge.',
auth(command) {
const result = command('claude', ['auth', 'status'])
if (result.status !== 0) return false
try { return JSON.parse(String(result.stdout || '')).loggedIn === true } catch { return false }
},
},
])
function json(res, status, body) {
const payload = JSON.stringify(body)
res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) })
res.end(payload)
}
function versionFrom(value) {
return String(value || '').match(/(?:^|[^0-9])v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/)?.[1] || ''
}
function commandResult(spawnSyncFn, command, args) {
const result = spawnSyncFn(command, args, { encoding: 'utf8', timeout: 5000 }) || {}
return {
status: Number.isInteger(result.status) ? result.status : 1,
stdout: String(result.stdout || ''),
}
}
function listSkills(kind, options = {}) {
const readdirSync = options.readdirSync || fs.readdirSync
const home = options.home || os.homedir()
const roots = {
codex: [path.join(home, '.codex', 'skills'), path.join(home, '.agents', 'skills')],
hermes: [path.join(home, '.hermes', 'skills'), path.join(home, '.agents', 'skills')],
claude: [path.join(home, '.claude', 'skills'), path.join(home, '.agents', 'skills')],
}[kind] || []
const names = new Set()
for (const root of roots) {
let entries = []
try { entries = readdirSync(root, { withFileTypes: true }) } catch { continue }
for (const entry of entries) {
if (names.size >= MAX_SKILLS) break
if ((entry.isDirectory?.() || entry.isSymbolicLink?.()) && PUBLIC_NAME.test(entry.name)) names.add(entry.name)
}
}
return [...names].sort().map(name => ({ namespace: kind, slug: name, name }))
}
function discoverAgents(options = {}) {
const spawnSyncFn = options.spawnSync || spawnSync
const listSkillsFn = options.listSkills || (kind => listSkills(kind))
const command = (name, args) => commandResult(spawnSyncFn, name, args)
const agents = []
for (const definition of AGENT_DEFINITIONS) {
const versionResult = command(definition.id, ['--version'])
const version = versionResult.status === 0 ? versionFrom(versionResult.stdout) : ''
if (!version) continue
const available = definition.auth(command)
agents.push({
id: definition.id,
sourceKind: definition.id,
label: definition.label,
version,
description: definition.description,
domains: ['general'],
inputTypes: ['text'],
permissionModes: ['read-only'],
available,
credentialState: available ? 'ready' : 'missing',
skills: listSkillsFn(definition.id),
session: {
supported: true,
resume: true,
cancel: true,
checkpoint: false,
},
})
}
return agents
}
function invocationFor(agentId, prompt, sessionRef = '') {
if (agentId === 'codex') {
return {
command: 'codex',
args: sessionRef
? ['exec', 'resume', '--json', '--skip-git-repo-check', '--sandbox', 'read-only', sessionRef, '-']
: ['exec', '--json', '--skip-git-repo-check', '--sandbox', 'read-only', '-'],
input: prompt,
}
}
if (agentId === 'hermes') {
return {
command: 'hermes',
args: ['chat', '--quiet', ...(sessionRef ? ['--resume', sessionRef] : []), '--query', prompt],
input: '',
}
}
if (agentId === 'claude') {
return {
command: 'claude',
args: [
'--print', '--output-format', 'json', '--permission-mode', 'plan',
...(sessionRef ? ['--resume', sessionRef] : []), prompt,
],
input: '',
}
}
throw new Error('REMOTE_AGENT_UNSUPPORTED')
}
function parseAgentOutput(agentId, stdout) {
if (agentId === 'codex') {
const messages = []
let sessionRef = ''
for (const line of String(stdout || '').split(/\r?\n/)) {
try {
const event = JSON.parse(line)
if (event.type === 'thread.started' && typeof event.thread_id === 'string') sessionRef = event.thread_id
if (event.type === 'item.completed' && event.item?.type === 'agent_message'
&& typeof event.item.text === 'string') messages.push(event.item.text)
} catch { /* ignore non-JSON diagnostics */ }
}
const text = messages.join('\n').trim()
return text ? { text, sessionRef, outcome: 'completed' } : null
}
if (agentId === 'hermes') {
const lines = String(stdout || '').split(/\r?\n/)
let sessionRef = ''
const text = lines.filter((line) => {
const match = line.match(/^session_id:\s*(\S+)\s*$/)
if (match) {
sessionRef = match[1]
return false
}
return true
}).join('\n').trim()
return text ? { text, sessionRef, outcome: 'completed' } : null
}
if (agentId === 'claude') {
let result
try { result = JSON.parse(String(stdout || '')) } catch { return null }
if (result?.is_error === true || typeof result?.result !== 'string' || !result.result.trim()) return null
return { text: result.result.trim(), sessionRef: String(result.session_id || ''), outcome: 'completed' }
}
return null
}
function readBody(req) {
return new Promise((resolve, reject) => {
let body = ''
req.on('data', chunk => {
body += chunk.toString('utf8')
if (Buffer.byteLength(body) > MAX_REQUEST_BYTES) reject(new Error('request too large'))
})
req.once('end', () => resolve(body))
req.once('error', reject)
})
}
function parentSessionDetached(parentPid = process.ppid, readFileSync = fs.readFileSync) {
if (!Number.isInteger(parentPid) || parentPid <= 1 || process.ppid === 1) return true
try {
const stat = String(readFileSync(`/proc/${parentPid}/stat`, 'utf8'))
const match = stat.match(/^\d+\s+\(.+\)\s+\S+\s+(\d+)\s/)
return match ? Number(match[1]) === 1 : false
} catch {
return true
}
}
function runAgent(agentId, prompt, sessionRef = '') {
return new Promise(resolve => {
let invocation
try { invocation = invocationFor(agentId, prompt, sessionRef) } catch {
resolve({ outcome: 'failed', failure: { code: 'REMOTE_AGENT_UNSUPPORTED' } })
return
}
const child = spawn(invocation.command, invocation.args, {
stdio: ['pipe', 'pipe', 'pipe'], cwd: process.env.HOME || undefined,
})
let stdout = ''
child.stdout.on('data', chunk => {
stdout += chunk.toString('utf8')
if (Buffer.byteLength(stdout) > MAX_RESPONSE_BYTES) child.kill('SIGTERM')
})
child.stderr.resume()
child.once('error', () => resolve({ outcome: 'failed', failure: { code: 'REMOTE_AGENT_UNAVAILABLE' } }))
child.once('close', code => {
if (code !== 0) return resolve({ outcome: 'failed', failure: { code: 'REMOTE_AGENT_FAILED' } })
resolve(parseAgentOutput(agentId, stdout)
|| { outcome: 'failed', failure: { code: 'REMOTE_AGENT_RESPONSE_INVALID' } })
})
child.stdin.end(invocation.input)
})
}
function createServer() {
return http.createServer(async (req, res) => {
if (req.method === 'GET' && req.url === '/.well-known/meldwork-agent-bridge') {
return json(res, 200, {
protocol: 'meldwork-agent-bridge', version: 1,
server: { id: 'ssh-session', label: 'SSH Agent Server' },
agents: discoverAgents(),
})
}
const match = req.method === 'POST' && req.url?.match(/^\/v1\/agents\/([A-Za-z0-9._-]+)\/runs$/)
if (!match) return json(res, 404, { error: 'NOT_FOUND' })
try {
const body = JSON.parse(await readBody(req))
const agent = discoverAgents().find(item => item.id === match[1] && item.available)
if (!agent || typeof body.prompt !== 'string' || body.permissionMode !== 'read-only') {
return json(res, 400, { error: 'REQUEST_INVALID' })
}
return json(res, 200, await runAgent(agent.id, body.prompt, body.sessionRef || ''))
} catch {
return json(res, 400, { error: 'REQUEST_INVALID' })
}
})
}
function startServer() {
const server = createServer()
const parentPid = process.ppid
const parentTimer = setInterval(() => {
if (!parentSessionDetached(parentPid)) return
server.close(() => process.exit(0))
}, 1000)
server.once('close', () => clearInterval(parentTimer))
server.listen(PORT, '127.0.0.1', () => {
process.stdout.write(`MELDWORK_AGENT_BRIDGE_READY ${PORT}\n`)
})
process.once('SIGTERM', () => server.close(() => process.exit(0)))
process.once('SIGINT', () => server.close(() => process.exit(0)))
return server
}
if (require.main === module || !require.main) startServer()
module.exports = {
createServer,
discoverAgents,
invocationFor,
listSkills,
parentSessionDetached,
parseAgentOutput,
runAgent,
startServer,
}
@@ -0,0 +1,701 @@
const crypto = require('node:crypto')
const { spawn } = require('node:child_process')
const fs = require('node:fs')
const net = require('node:net')
const { canonicalJson } = require('../../collaboration/outcome-records.cjs')
const { atomicWritePrivateFile } = require('../../security/private-file.cjs')
const { createAgentConnectorManifest } = require('../connectors/agent-connector-manifest.cjs')
const BRIDGE_PROTOCOL = 'meldwork-agent-bridge'
const BRIDGE_VERSION = 1
const BRIDGE_RECIPE_ID = 'external.cloud-agent-bridge'
const SSH_RECIPE_ID = 'external.cloud-agent-ssh'
const BRIDGE_UPSTREAM_ID = 'meldwork-cloud'
const BRIDGE_UPSTREAM_VERSION = '1.0.0'
const BRIDGE_ID = /^cloud-bridge-[a-f0-9]{24}$/
const PUBLIC_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/
const MAX_RESPONSE_BYTES = 256 * 1024
const MAX_AGENTS = 32
const MAX_SKILLS = 64
const STORE_VERSION = 1
const SSH_DISCOVERY_COMMAND = 'if command -v codex >/dev/null 2>&1; then codex --version; fi'
const SSH_CODEX_COMMAND = 'exec codex exec --json --skip-git-repo-check --sandbox read-only -'
const SSH_TUNNEL_READY = 'MELDWORK_AGENT_BRIDGE_READY'
const SSH_BRIDGE_SCRIPT = fs.readFileSync(require.resolve('./cloud-agent-bridge-server.cjs'), 'utf8')
function loginShellCommand(command) {
const escaped = String(command).replace(/'/g, `'"'"'`)
return `bash -lc '${escaped}'`
}
function bridgeError(code) {
const error = new Error(code)
error.code = code
return error
}
function fail(code) {
throw bridgeError(code)
}
function clone(value) {
return JSON.parse(canonicalJson(value))
}
function cleanText(value, max = 240) {
const text = String(value || '').trim()
if (!text || text.length > max || /[\u0000-\u001f\u007f]/.test(text)) return ''
return text
}
function normalizeSkill(input) {
if (!input || typeof input !== 'object' || Array.isArray(input)) return null
const namespace = cleanText(input.namespace, 80)
const slug = cleanText(input.slug, 120)
const name = cleanText(input.name || input.slug, 120)
if (!PUBLIC_ID.test(namespace) || !PUBLIC_ID.test(slug) || !name) return null
return { namespace, slug, name }
}
function normalizeAddress(value) {
const raw = String(value || '').trim().replace(/\/$/, '')
const address = /^[a-z][a-z\d+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`
let parsed
try { parsed = new URL(address) } catch { fail('CLOUD_AGENT_BRIDGE_ADDRESS_INVALID') }
if (!['https:'].includes(parsed.protocol)
|| parsed.username || parsed.password || parsed.pathname !== '/'
|| parsed.search || parsed.hash || !parsed.hostname
|| net.isIP(parsed.hostname) === 0) {
fail('CLOUD_AGENT_BRIDGE_ADDRESS_INVALID')
}
return parsed.origin
}
function normalizeSshAddress(value) {
const raw = String(value || '').trim()
if (!raw || /^[a-z][a-z\d+.-]*:\/\//i.test(raw) || /[\s@/?#]/.test(raw)) {
fail('CLOUD_AGENT_SSH_ADDRESS_INVALID')
}
const match = raw.match(/^([^:]+)(?::(\d{1,5}))?$/)
if (!match || net.isIP(match[1]) === 0) fail('CLOUD_AGENT_SSH_ADDRESS_INVALID')
const port = match[2] ? Number(match[2]) : 22
if (!Number.isInteger(port) || port < 1 || port > 65535) fail('CLOUD_AGENT_SSH_ADDRESS_INVALID')
return { host: match[1], port, address: port === 22 ? match[1] : `${match[1]}:${port}` }
}
function normalizeTunnelEndpoint(value) {
let parsed
try { parsed = new URL(String(value || '')) } catch { fail('CLOUD_AGENT_SSH_TUNNEL_INVALID') }
if (parsed.protocol !== 'http:' || parsed.hostname !== '127.0.0.1'
|| parsed.pathname !== '/' || parsed.search || parsed.hash
|| parsed.username || parsed.password || !parsed.port) {
fail('CLOUD_AGENT_SSH_TUNNEL_INVALID')
}
return parsed.origin
}
function normalizeAgent(input) {
if (!input || typeof input !== 'object' || Array.isArray(input)) {
fail('CLOUD_AGENT_BRIDGE_MANIFEST_INVALID')
}
const id = cleanText(input.id, 80)
const sourceKind = cleanText(input.sourceKind || input.id, 80)
const label = cleanText(input.label || input.name, 120)
const version = cleanText(input.version, 80)
if (!PUBLIC_ID.test(id) || !PUBLIC_ID.test(sourceKind) || !label || !version) {
fail('CLOUD_AGENT_BRIDGE_MANIFEST_INVALID')
}
const domains = Array.isArray(input.domains) && input.domains.length
? input.domains.map(item => cleanText(item, 80)).filter(item => PUBLIC_ID.test(item))
: ['general']
const inputTypes = Array.isArray(input.inputTypes) && input.inputTypes.length
? input.inputTypes.filter(item => ['text', 'image', 'audio', 'video', 'file', 'structured-data'].includes(item))
: ['text']
const permissionModes = Array.isArray(input.permissionModes) && input.permissionModes.length
? input.permissionModes.filter(item => ['read-only', 'workspace-write'].includes(item))
: ['read-only']
if (!domains.length || !inputTypes.includes('text') || !permissionModes.length) {
fail('CLOUD_AGENT_BRIDGE_MANIFEST_INVALID')
}
const credentialState = ['ready', 'missing', 'unknown'].includes(input.credentialState)
? input.credentialState
: (input.available === false ? 'unknown' : 'ready')
const skills = (Array.isArray(input.skills) ? input.skills : [])
.map(normalizeSkill)
.filter(Boolean)
.slice(0, MAX_SKILLS)
return {
id, sourceKind, label, version,
description: cleanText(input.description, 400),
available: input.available !== false,
credentialState,
skills,
domains: [...new Set(domains)].slice(0, 16),
inputTypes: [...new Set(inputTypes)].slice(0, 16),
permissionModes: [...new Set(permissionModes)],
session: {
supported: input.session?.supported !== false,
resume: input.session?.resume !== false,
cancel: input.session?.cancel !== false,
checkpoint: false,
},
}
}
function normalizeProbe(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)
|| value.protocol !== BRIDGE_PROTOCOL || value.version !== BRIDGE_VERSION
|| !Array.isArray(value.agents) || !value.agents.length || value.agents.length > MAX_AGENTS) {
fail('CLOUD_AGENT_BRIDGE_MANIFEST_INVALID')
}
const serverLabel = cleanText(value.server?.label || value.server?.name, 120)
return {
serverId: cleanText(value.server?.id, 120),
serverLabel: serverLabel || 'Cloud Agent Server',
agents: value.agents.map(normalizeAgent),
}
}
function bridgeIdFor(address) {
return `cloud-bridge-${crypto.createHash('sha256').update(address).digest('hex').slice(0, 24)}`
}
function instanceIdFor(bridgeId, agentId) {
return `cloud-${crypto.createHash('sha256').update(`${bridgeId}:${agentId}`).digest('hex').slice(0, 24)}`
}
function manifestFor(record, agent) {
const ssh = record.transport === 'ssh'
const tunnel = record.transport === 'ssh-tunnel'
return createAgentConnectorManifest({
connectorId: instanceIdFor(record.bridgeId, agent.id),
connectorVersion: '1.0.0',
kind: 'agent',
label: `${agent.label} @ ${record.label}`,
description: agent.description || `Agent CLI exposed by ${record.label}.`,
transport: ssh ? { type: 'cli', protocol: 'jsonl' } : { type: 'http', protocol: 'json' },
upstream: ssh
? { id: agent.id, minVersion: BRIDGE_UPSTREAM_VERSION, maxVersion: BRIDGE_UPSTREAM_VERSION }
: { id: BRIDGE_UPSTREAM_ID, minVersion: BRIDGE_UPSTREAM_VERSION, maxVersion: BRIDGE_UPSTREAM_VERSION },
invocation: { recipeId: ssh ? SSH_RECIPE_ID : BRIDGE_RECIPE_ID, idempotencyMode: 'durable' },
domains: agent.domains,
session: agent.session,
inputTypes: agent.inputTypes,
permissionModes: agent.permissionModes,
eventProtocolVersion: 1,
eventTypes: ['Completed', 'Failed', 'Cancelled'],
usage: {
inputTokens: false, outputTokens: false, costMicros: false,
toolCalls: false, outboundBytes: !ssh && !tunnel, elapsedMs: true,
},
outboundDestinations: ssh || tunnel ? [] : [record.address],
credentials: { mode: 'none', slots: [] },
license: 'LicenseRef-Meldwork-NC',
})
}
function instanceFor(record, agent, manifest) {
return {
instanceId: manifest.connectorId,
connectorId: manifest.connectorId,
connectorVersion: manifest.connectorVersion,
upstreamVersion: BRIDGE_UPSTREAM_VERSION,
label: manifest.label,
credentialRef: null,
manifestId: manifest.manifestId,
bridgeId: record.bridgeId,
agentId: agent.id,
}
}
class CloudAgentBridgeStore {
constructor(options = {}) {
if (!options || typeof options.storagePath !== 'string' || !options.storagePath
|| typeof options.fetch !== 'function') {
fail('CLOUD_AGENT_BRIDGE_OPTIONS_INVALID')
}
this.storagePath = options.storagePath
this.fetch = options.fetch
this.spawn = options.spawn || spawn
this.sshExecute = typeof options.sshExecute === 'function' ? options.sshExecute : null
this.sshTunnelStart = typeof options.sshTunnelStart === 'function' ? options.sshTunnelStart : null
this.tunnels = new Map()
this.timeoutMs = Number.isFinite(options.timeoutMs)
? Math.max(1000, Math.min(5 * 60 * 1000, Math.floor(options.timeoutMs)))
: 3 * 60 * 1000
this.records = this.read().records
}
read() {
try {
const parsed = JSON.parse(fs.readFileSync(this.storagePath, 'utf8'))
if (parsed?.version !== STORE_VERSION || !Array.isArray(parsed.records)) throw new Error('invalid')
return { version: STORE_VERSION, records: parsed.records.map(value => this.normalizeRecord(value)) }
} catch {
return { version: STORE_VERSION, records: [] }
}
}
normalizeRecord(value) {
const transport = ['ssh', 'ssh-tunnel'].includes(value?.transport) ? value.transport : 'bridge'
const sshAddress = ['ssh', 'ssh-tunnel'].includes(transport) ? normalizeSshAddress(value?.address) : null
const address = sshAddress?.address || normalizeAddress(value?.address)
const bridgeId = String(value?.bridgeId || bridgeIdFor(`${transport}:${address}`))
if (!BRIDGE_ID.test(bridgeId)) fail('CLOUD_AGENT_BRIDGE_STORE_INVALID')
const agents = Array.isArray(value?.agents) ? value.agents.map(normalizeAgent).slice(0, MAX_AGENTS) : []
return {
bridgeId,
transport,
address,
endpoint: transport === 'ssh-tunnel' ? normalizeTunnelEndpoint(value?.endpoint) : '',
label: cleanText(value?.label, 120) || (transport === 'ssh' ? address : 'Cloud Agent Server'),
serverId: cleanText(value?.serverId, 120),
agents,
available: transport === 'ssh-tunnel' ? false : value?.available === true,
lastError: cleanText(value?.lastError, 120),
checkedAt: cleanText(value?.checkedAt, 40),
}
}
persist() {
atomicWritePrivateFile(this.storagePath, `${JSON.stringify({ version: STORE_VERSION, records: this.records })}\n`)
}
async request(pathname, options = {}) {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), this.timeoutMs)
const abortFromCaller = () => controller.abort()
options.signal?.addEventListener?.('abort', abortFromCaller, { once: true })
try {
const response = await this.fetch(`${options.address}${pathname}`, {
method: options.method || 'GET',
redirect: 'error',
signal: controller.signal,
headers: { accept: 'application/json', ...(options.body ? { 'content-type': 'application/json' } : {}) },
...(options.body ? { body: JSON.stringify(options.body) } : {}),
})
if (!response?.ok) throw bridgeError(`CLOUD_AGENT_BRIDGE_HTTP_${response?.status || 'FAILED'}`)
const text = await response.text()
if (Buffer.byteLength(text, 'utf8') > MAX_RESPONSE_BYTES) fail('CLOUD_AGENT_BRIDGE_RESPONSE_TOO_LARGE')
try { return JSON.parse(text) } catch { fail('CLOUD_AGENT_BRIDGE_RESPONSE_INVALID') }
} catch (error) {
if (error?.name === 'AbortError') fail('CLOUD_AGENT_BRIDGE_TIMEOUT')
throw error
} finally {
clearTimeout(timer)
options.signal?.removeEventListener?.('abort', abortFromCaller)
}
}
async probe(address) {
const normalizedAddress = normalizeAddress(address)
return normalizeProbe(await this.request('/.well-known/meldwork-agent-bridge', { address: normalizedAddress }))
}
async sshCommand(address, command, input = '', signal) {
const parsed = normalizeSshAddress(address)
if (signal?.aborted) fail('CLOUD_AGENT_SSH_CANCELLED')
if (this.sshExecute) return this.sshExecute({
address: parsed.address, command, input, signal,
})
const args = [
'-T', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', '-o', 'StrictHostKeyChecking=yes',
...(parsed.port === 22 ? [] : ['-p', String(parsed.port)]),
parsed.host, command,
]
return new Promise((resolve, reject) => {
let stdout = ''
let stderr = ''
let settled = false
const child = this.spawn('ssh', args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
const finish = (error, value) => {
if (settled) return
settled = true
signal?.removeEventListener?.('abort', abort)
if (error) reject(error)
else resolve(value)
}
const append = (target, chunk) => {
const next = `${target}${Buffer.from(chunk).toString('utf8')}`
if (Buffer.byteLength(next, 'utf8') > MAX_RESPONSE_BYTES) {
child.kill('SIGTERM')
finish(bridgeError('CLOUD_AGENT_SSH_RESPONSE_TOO_LARGE'))
return target
}
return next
}
const abort = () => {
child.kill('SIGTERM')
finish(bridgeError('CLOUD_AGENT_SSH_CANCELLED'))
}
child.once('error', () => finish(bridgeError('CLOUD_AGENT_SSH_UNAVAILABLE')))
child.stdout.on('data', chunk => { stdout = append(stdout, chunk) })
child.stderr.on('data', chunk => { stderr = append(stderr, chunk) })
child.once('close', code => {
if (signal?.aborted) return finish(bridgeError('CLOUD_AGENT_SSH_CANCELLED'))
if (code !== 0) return finish(bridgeError('CLOUD_AGENT_SSH_UNAVAILABLE'))
finish(null, { stdout, stderr })
})
signal?.addEventListener?.('abort', abort, { once: true })
child.stdin.end(input)
})
}
async allocateLocalPort() {
return new Promise((resolve, reject) => {
const server = net.createServer()
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
const port = server.address()?.port
server.close(error => error ? reject(error) : resolve(port))
})
})
}
async startSshTunnel(address) {
const parsed = normalizeSshAddress(address)
if (this.sshTunnelStart) return this.sshTunnelStart({ address: parsed.address })
const localPort = await this.allocateLocalPort()
const remotePort = localPort
const args = [
'-T', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', '-o', 'StrictHostKeyChecking=yes',
'-L', `127.0.0.1:${localPort}:127.0.0.1:${remotePort}`,
...(parsed.port === 22 ? [] : ['-p', String(parsed.port)]),
parsed.host, loginShellCommand(`MELDWORK_AGENT_BRIDGE_PORT=${remotePort} exec node -`),
]
const child = this.spawn('ssh', args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
child.stderr.resume()
return new Promise((resolve, reject) => {
let output = ''
let settled = false
const timer = setTimeout(() => finish(bridgeError('CLOUD_AGENT_SSH_TUNNEL_TIMEOUT')), this.timeoutMs)
const finish = error => {
if (settled) return
settled = true
clearTimeout(timer)
if (error) {
child.kill('SIGTERM')
reject(error)
return
}
resolve({ child, endpoint: `http://127.0.0.1:${localPort}` })
}
child.once('error', () => finish(bridgeError('CLOUD_AGENT_SSH_TUNNEL_UNAVAILABLE')))
child.stdout.on('data', chunk => {
output += Buffer.from(chunk).toString('utf8')
if (Buffer.byteLength(output, 'utf8') > 16 * 1024) {
finish(bridgeError('CLOUD_AGENT_SSH_TUNNEL_PROTOCOL'))
return
}
if (new RegExp(`${SSH_TUNNEL_READY}\\s+${remotePort}\\b`).test(output)) finish()
})
child.once('close', code => {
if (!settled) finish(bridgeError(code === 0
? 'CLOUD_AGENT_SSH_TUNNEL_UNAVAILABLE'
: 'CLOUD_AGENT_SSH_TUNNEL_FAILED'))
})
child.stdin.end(SSH_BRIDGE_SCRIPT)
})
}
stopTunnel(bridgeId) {
const tunnel = this.tunnels.get(bridgeId)
if (!tunnel) return
this.tunnels.delete(bridgeId)
try { tunnel.child?.kill('SIGTERM') } catch { /* already closed */ }
}
async ensureSshTunnel(record) {
const existing = this.tunnels.get(record.bridgeId)
if (existing?.child && !existing.child.killed) return existing.endpoint
const tunnel = await this.startSshTunnel(record.address)
this.tunnels.set(record.bridgeId, tunnel)
tunnel.child?.once?.('close', () => {
if (this.tunnels.get(record.bridgeId) === tunnel) this.tunnels.delete(record.bridgeId)
})
return tunnel.endpoint
}
async probeSsh(address) {
const result = await this.sshCommand(address, SSH_DISCOVERY_COMMAND)
const version = String(result.stdout || '').match(/\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/)?.[1]
if (!version) fail('CLOUD_AGENT_SSH_NO_AGENT')
return {
serverId: '',
serverLabel: normalizeSshAddress(address).address,
agents: [{
id: 'codex', label: 'Codex CLI', version, description: '', domains: ['general'],
inputTypes: ['text'], permissionModes: ['read-only'],
session: { supported: false, resume: false, cancel: true, checkpoint: false },
}],
}
}
async connect(input = {}) {
const rawAddress = String(input.address || '').trim()
let sshError = null
if (!/^[a-z][a-z\d+.-]*:\/\//i.test(rawAddress)) {
try { return await this.connectSshTunnel(input) } catch (error) { sshError = error }
try { return await this.connectSsh(input) } catch (error) { sshError = error }
}
try {
const address = normalizeAddress(input.address)
const probe = await this.probe(address)
const bridgeId = bridgeIdFor(address)
const record = this.normalizeRecord({
bridgeId,
address,
label: cleanText(input.label, 120) || probe.serverLabel,
serverId: probe.serverId,
agents: probe.agents,
available: true,
lastError: '',
checkedAt: new Date().toISOString(),
})
const index = this.records.findIndex(item => item.bridgeId === bridgeId)
if (index >= 0) this.records[index] = record
else this.records.push(record)
this.records.sort((left, right) => left.label.localeCompare(right.label) || left.bridgeId.localeCompare(right.bridgeId))
this.persist()
return this.publicRecord(record)
} catch (error) {
throw sshError || error
}
}
async connectSsh(input = {}) {
const sshAddress = normalizeSshAddress(input.address)
const probe = await this.probeSsh(sshAddress.address)
const bridgeId = bridgeIdFor(`ssh:${sshAddress.address}`)
const record = this.normalizeRecord({
bridgeId,
transport: 'ssh',
address: sshAddress.address,
label: cleanText(input.label, 120) || probe.serverLabel,
serverId: probe.serverId,
agents: probe.agents,
available: true,
lastError: '',
checkedAt: new Date().toISOString(),
})
const index = this.records.findIndex(item => item.bridgeId === bridgeId)
if (index >= 0) this.records[index] = record
else this.records.push(record)
this.records.sort((left, right) => left.label.localeCompare(right.label) || left.bridgeId.localeCompare(right.bridgeId))
this.persist()
return this.publicRecord(record)
}
async connectSshTunnel(input = {}) {
const sshAddress = normalizeSshAddress(input.address)
const existing = this.records.find(record => (
record.address === sshAddress.address && ['ssh', 'ssh-tunnel'].includes(record.transport)
))
const bridgeId = existing?.bridgeId || bridgeIdFor(`ssh-tunnel:${sshAddress.address}`)
const endpoint = await this.ensureSshTunnel({ bridgeId, address: sshAddress.address })
try {
const probe = normalizeProbe(await this.request('/.well-known/meldwork-agent-bridge', { address: endpoint }))
const record = this.normalizeRecord({
bridgeId,
transport: 'ssh-tunnel',
address: sshAddress.address,
endpoint,
label: cleanText(input.label, 120) || probe.serverLabel,
serverId: probe.serverId,
agents: probe.agents,
available: true,
lastError: '',
checkedAt: new Date().toISOString(),
})
record.available = true
const index = this.records.findIndex(item => item.bridgeId === bridgeId)
if (index >= 0) this.records[index] = record
else this.records.push(record)
this.records = this.records.filter(item => (
item.bridgeId === bridgeId || item.address !== sshAddress.address
))
this.records.sort((left, right) => left.label.localeCompare(right.label) || left.bridgeId.localeCompare(right.bridgeId))
this.persist()
return this.publicRecord(record)
} catch (error) {
this.stopTunnel(bridgeId)
throw error
}
}
async refresh() {
for (const current of this.records) {
try {
let probe
if (current.transport === 'ssh-tunnel') {
current.endpoint = await this.ensureSshTunnel(current)
probe = normalizeProbe(await this.request('/.well-known/meldwork-agent-bridge', {
address: current.endpoint,
}))
current.available = true
} else if (current.transport === 'ssh') {
try {
current.endpoint = await this.ensureSshTunnel(current)
probe = normalizeProbe(await this.request('/.well-known/meldwork-agent-bridge', {
address: current.endpoint,
}))
current.transport = 'ssh-tunnel'
current.available = true
} catch {
probe = await this.probeSsh(current.address)
}
} else {
probe = await this.probe(current.address)
}
Object.assign(current, {
serverId: probe.serverId,
agents: probe.agents.map(normalizeAgent),
available: true,
lastError: '', checkedAt: new Date().toISOString(),
})
} catch (error) {
if (current.transport === 'ssh-tunnel') this.stopTunnel(current.bridgeId)
current.available = false
current.lastError = String(error?.code || 'CLOUD_AGENT_BRIDGE_PROBE_FAILED').slice(0, 120)
}
}
this.persist()
return this.list()
}
publicRecord(record) {
return clone({
bridgeId: record.bridgeId, address: record.address, label: record.label,
transport: record.transport,
serverId: record.serverId, available: record.available,
lastError: record.lastError, checkedAt: record.checkedAt,
agents: record.agents.map(agent => ({
id: agent.id, sourceKind: agent.sourceKind, label: agent.label, version: agent.version,
description: agent.description, domains: agent.domains,
available: agent.available,
credentialState: agent.credentialState,
skills: agent.skills,
})),
})
}
list() {
return this.records.map(record => this.publicRecord(record))
}
remove(bridgeId) {
const id = String(bridgeId || '')
const index = this.records.findIndex(record => record.bridgeId === id)
if (index < 0) fail('CLOUD_AGENT_BRIDGE_NOT_FOUND')
this.stopTunnel(id)
this.records.splice(index, 1)
this.persist()
return { deleted: true, bridgeId: id }
}
connectorEntries() {
return this.records.filter(record => record.available).flatMap(record => record.agents
.filter(agent => agent.available !== false)
.map(agent => {
const manifest = manifestFor(record, agent)
return { record: clone(record), agent: clone(agent), manifest, instance: instanceFor(record, agent, manifest) }
}))
}
catalogEntries() {
return this.records.flatMap(record => record.agents.map(agent => ({
kind: instanceIdFor(record.bridgeId, agent.id),
sourceKind: agent.sourceKind,
label: `${agent.label} @ ${record.label}`,
name: `${agent.label} @ ${record.label}`,
description: agent.description,
version: agent.version,
installed: true,
installSupported: false,
installErrorCode: '',
providerCompatible: false,
providerMode: 'connector',
imageAttachmentLimit: 0,
custom: false,
connector: true,
cloud: true,
available: record.available && agent.available !== false,
credentialState: agent.credentialState,
serverLabel: record.label,
})))
}
skillsForInstance(instanceId) {
const id = String(instanceId || '')
for (const record of this.records) {
const agent = record.agents.find(item => instanceIdFor(record.bridgeId, item.id) === id)
if (agent) return clone(agent.skills)
}
return null
}
async run(input) {
const bridgeId = String(input?.bridgeId || '')
const agentId = String(input?.agentId || '')
const record = this.records.find(item => item.bridgeId === bridgeId && item.available)
if (!record || !PUBLIC_ID.test(agentId)) fail('CLOUD_AGENT_BRIDGE_NOT_FOUND')
const agent = record.agents.find(item => item.id === agentId)
if (!agent) fail('CLOUD_AGENT_BRIDGE_AGENT_NOT_FOUND')
if (agent.available === false) fail('CLOUD_AGENT_BRIDGE_AGENT_UNAVAILABLE')
if (record.transport === 'ssh') return this.runSsh(record, agent, input)
return this.request(`/v1/agents/${encodeURIComponent(agentId)}/runs`, {
address: record.transport === 'ssh-tunnel' ? record.endpoint : record.address,
method: 'POST',
signal: input.signal,
body: {
prompt: input.prompt,
sessionRef: input.sessionRef || null,
permissionMode: input.permissionMode,
operationId: input.operationId,
resume: input.resume || null,
},
})
}
async runSsh(record, agent, input) {
if (agent.id !== 'codex' || input.permissionMode !== 'read-only') {
fail('CLOUD_AGENT_SSH_PERMISSION_UNSUPPORTED')
}
const result = await this.sshCommand(record.address, SSH_CODEX_COMMAND, input.prompt, input.signal)
const messages = []
let sessionRef = ''
for (const line of String(result.stdout || '').split('\n')) {
try {
const event = JSON.parse(line)
if (event.type === 'thread.started' && typeof event.thread_id === 'string') sessionRef = event.thread_id
if (event.type === 'item.completed' && event.item?.type === 'agent_message'
&& typeof event.item.text === 'string') messages.push(event.item.text)
} catch { /* SSH transport permits only Codex JSONL output */ }
}
const text = messages.join('\n').trim()
if (!text) fail('CLOUD_AGENT_SSH_RESPONSE_INVALID')
return { text, sessionRef: '', outcome: 'completed' }
}
bridgeForInstance(instanceId) {
for (const entry of this.connectorEntries()) {
if (entry.instance.instanceId === instanceId) return entry
}
return null
}
close() {
for (const bridgeId of this.tunnels.keys()) this.stopTunnel(bridgeId)
}
}
module.exports = {
BRIDGE_RECIPE_ID,
SSH_RECIPE_ID,
CloudAgentBridgeStore,
loginShellCommand,
manifestFor,
normalizeAddress,
normalizeProbe,
}
@@ -17,6 +17,7 @@ const {
MAX_PACKAGE_BYTES,
createAgentConnectorPackage,
} = require('./agent-connector-package-store.cjs')
const { BRIDGE_RECIPE_ID, SSH_RECIPE_ID } = require('../cloud/cloud-agent-bridge.cjs')
const LOCAL_DELEGATE_RECIPE_ID = 'external.local-agent.delegate'
const MAX_LOCAL_MANIFESTS = 64
@@ -137,7 +138,7 @@ function fail(code) {
function exactOptions(value) {
return value && typeof value === 'object' && !Array.isArray(value)
&& Object.keys(value).every(key => [
'fetch', 'instanceStore', 'manifestDirectory', 'packageStore', 'runAgent', 'seedSample',
'cloudBridges', 'fetch', 'instanceStore', 'manifestDirectory', 'packageStore', 'runAgent', 'seedSample',
].includes(key))
}
@@ -240,6 +241,7 @@ class LocalAgentConnectors {
this.upstreams = new Map()
this.detectedUpstreams = []
this.lastDiagnostics = []
this.cloudBridges = options.cloudBridges || null
}
createRegistry() {
@@ -247,7 +249,7 @@ class LocalAgentConnectors {
? this.packageStore.installedPackages().map(item => item.manifest)
: []
return new AgentConnectorRegistry({
approvedRecipeIds: [LOCAL_DELEGATE_RECIPE_ID, ...SDK_RECIPE_IDS],
approvedRecipeIds: [LOCAL_DELEGATE_RECIPE_ID, BRIDGE_RECIPE_ID, SSH_RECIPE_ID, ...SDK_RECIPE_IDS],
approvedExternalManifestIds: [
...APPROVED_AGENT_CONNECTOR_MANIFESTS.map(item => item.manifestId),
...installedPackageManifests.map(item => item.manifestId),
@@ -262,6 +264,8 @@ class LocalAgentConnectors {
[LOCAL_DELEGATE_RECIPE_ID]: input => this.runDelegated(input),
[SDK_LOCAL_ECHO_RECIPE_ID]: input => this.runLocalEcho(input),
[SDK_HTTP_JSON_RECIPE_ID]: input => this.runHttpJson(input),
[BRIDGE_RECIPE_ID]: input => this.runCloudBridge(input),
[SSH_RECIPE_ID]: input => this.runCloudBridge(input),
},
})
}
@@ -350,6 +354,29 @@ class LocalAgentConnectors {
diagnostics.push({ name: 'instances', code: safeFailureCode(error) })
}
const manifests = registry.listManifests()
for (const entry of this.cloudBridges?.connectorEntries?.() || []) {
try {
const manifest = registry.registerBuiltin(entry.manifest)
const instance = registry.registerInstance({
instanceId: entry.instance.instanceId,
connectorId: manifest.connectorId,
connectorVersion: manifest.connectorVersion,
upstreamVersion: entry.instance.upstreamVersion,
label: entry.instance.label,
credentialRef: null,
})
upstreams.set(instance.instanceId, Object.freeze({
kind: instance.instanceId,
label: instance.label,
name: instance.label,
version: instance.upstreamVersion,
compatibilityState: 'compatible',
cloud: true,
}))
} catch (error) {
diagnostics.push({ name: entry.instance.instanceId, code: safeFailureCode(error) })
}
}
for (const stored of storedInstances) {
const manifest = manifests.find(item => (
item.manifestId === stored.manifestId
@@ -405,9 +432,10 @@ class LocalAgentConnectors {
detectAgents() {
return this.runtime.detectAgents().map((agent) => {
const { instance } = this.registry.resolveInstance(agent.connectorInstanceId)
const { instance, manifest } = this.registry.resolveInstance(agent.connectorInstanceId)
return Object.freeze({
...agent,
cloud: [BRIDGE_RECIPE_ID, SSH_RECIPE_ID].includes(manifest.invocation.recipeId),
credentialConfigured: Boolean(instance.credentialRef),
})
}).sort((left, right) => (
@@ -563,20 +591,26 @@ class LocalAgentConnectors {
catalog() {
return this.detectAgents().map((agent) => {
const { manifest } = this.registry.resolveInstance(agent.connectorInstanceId)
const cloud = [BRIDGE_RECIPE_ID, SSH_RECIPE_ID].includes(manifest.invocation.recipeId)
const cloudEntry = cloud ? this.cloudBridges?.bridgeForInstance?.(agent.kind) : null
return Object.freeze({
kind: agent.kind,
sourceKind: cloudEntry?.agent?.sourceKind || '',
label: agent.label,
name: agent.name,
description: manifest.description,
version: agent.version,
version: cloudEntry?.agent?.version || agent.version,
installed: true,
installSupported: false,
installErrorCode: '',
providerCompatible: false,
providerMode: 'connector',
imageAttachmentLimit: manifest.inputTypes.includes('image') ? 32 : 0,
custom: true,
custom: !cloud,
connector: true,
cloud,
credentialState: cloudEntry?.agent?.credentialState || 'unknown',
serverLabel: cloudEntry?.record?.label || '',
connectorId: manifest.connectorId,
connectorVersion: manifest.connectorVersion,
upstreamVersion: agent.upstreamVersion,
@@ -768,6 +802,63 @@ class LocalAgentConnectors {
return { outcome: 'failed', failure }
}
}
async runCloudBridge(input) {
const entry = this.cloudBridges?.bridgeForInstance?.(input.connector.instanceId)
if (!entry) fail('CLOUD_AGENT_BRIDGE_NOT_FOUND')
const terminal = {
eventId: `${input.agentRunId}:terminal`,
cursor: `${input.agentRunId}:terminal`,
sequence: 1,
}
try {
if (!['ssh', 'ssh-tunnel'].includes(entry.record.transport)) {
input.onOutboundPayload({ destination: entry.record.address, transport: 'http' })
}
const result = await this.cloudBridges.run({
bridgeId: entry.record.bridgeId,
agentId: entry.agent.id,
prompt: input.prompt,
sessionRef: input.sessionRef,
// Cloud bridge tasks never receive local workspace write authority.
permissionMode: 'read-only',
operationId: input.operationId,
resume: input.resume,
signal: input.signal,
})
if (!result || typeof result !== 'object' || Array.isArray(result)
|| typeof result.text !== 'string' || result.text.includes('\u0000')
|| (result.sessionRef !== undefined
&& (typeof result.sessionRef !== 'string' || result.sessionRef.length > 8192
|| /[\u0000-\u001f\u007f]/.test(result.sessionRef)))
|| !['completed', 'partial', 'failed', 'cancelled'].includes(result.outcome || 'completed')) {
fail('CLOUD_AGENT_BRIDGE_RESPONSE_INVALID')
}
if (result.outcome === 'failed') {
const failure = failureDetails(result.failure || result)
input.emit({ ...terminal, type: 'Failed', ...failure })
return { outcome: 'failed', failure }
}
if (result.outcome === 'cancelled') {
input.emit({ ...terminal, type: 'Cancelled', reason: 'user' })
return { outcome: 'cancelled', sessionRef: result.sessionRef || input.sessionRef || '' }
}
input.emit({ ...terminal, type: 'Completed', outcome: result.outcome || 'completed' })
return {
text: result.text,
sessionRef: typeof result.sessionRef === 'string' ? result.sessionRef : input.sessionRef || '',
outcome: result.outcome || 'completed',
}
} catch (error) {
if (input.signal?.aborted || error?.name === 'AbortError') {
input.emit({ ...terminal, type: 'Cancelled', reason: 'user' })
return { outcome: 'cancelled', sessionRef: input.sessionRef || '' }
}
const failure = failureDetails(error)
input.emit({ ...terminal, type: 'Failed', ...failure })
return { outcome: 'failed', failure }
}
}
}
module.exports = {
+22 -2
View File
@@ -29,6 +29,7 @@ const { AgentConnectorInstanceStore } = require('./agents/connectors/agent-conne
const { LocalAgentConnectors } = require('./agents/connectors/agent-connector-local.cjs')
const { AgentConnectorPackageStore } = require('./agents/connectors/agent-connector-package-store.cjs')
const { CloudAgentOperationStore } = require('./agents/cloud/cloud-agent-operation-store.cjs')
const { CloudAgentBridgeStore } = require('./agents/cloud/cloud-agent-bridge.cjs')
const {
CloudAgentRuntime,
createCloudAgentStartRetry = ({ runtime }) => Object.freeze({
@@ -92,6 +93,7 @@ let providerStore = null
let customAgentStore = null
let agentConnectors = null
let cloudAgentRuntime = null
let cloudAgentBridges = null
let cloudAgentStartRetry = null
let channelIngressRuntime = null
let knowledgeBaseStore = null
@@ -506,6 +508,7 @@ function createWorkspace() {
safeStorage: lazySafeStorage,
}),
runAgent: runCoreAgent,
cloudBridges: cloudAgentBridges,
})
const outcomeStore = new OutcomeStore({
rootPath: path.join(privateRoot, 'outcomes'),
@@ -551,6 +554,7 @@ function createWorkspace() {
agentFitMatrix: FROZEN_AGENT_FIT_MATRIX,
runLedger: cloudAgentRuntime.workspaceLedger(),
detectAgents: async () => {
await cloudAgentBridges?.refresh()
const [installedAgents, customAgents] = await Promise.all([
installer.detectedAgents(),
customAgentStore.detectAgents(),
@@ -610,6 +614,7 @@ function createWorkspace() {
}
async function localAgentCatalog() {
await cloudAgentBridges?.refresh()
const [catalog, customAgents, installedAgents] = await Promise.all([
installer.catalog(),
customAgentStore.catalog(),
@@ -617,10 +622,17 @@ async function localAgentCatalog() {
])
agentConnectors?.refresh(installedAgents)
const connectorAgents = agentConnectors?.catalog() || []
const mergedAgents = new Map()
for (const agent of [...catalog.agents, ...customAgents, ...connectorAgents]) {
mergedAgents.set(agent.kind, agent)
}
for (const agent of cloudAgentBridges?.catalogEntries?.() || []) {
mergedAgents.set(agent.kind, { ...(mergedAgents.get(agent.kind) || {}), ...agent })
}
const states = new Map((workspace?.snapshot().agents || []).map(agent => [agent.kind, agent]))
return {
...catalog,
agents: [...catalog.agents, ...customAgents, ...connectorAgents].map((agent) => {
agents: [...mergedAgents.values()].map((agent) => {
const state = states.get(agent.kind)
const support = localAttachmentSupport(agent.kind)
return {
@@ -635,7 +647,9 @@ async function localAgentCatalog() {
recentlyVerified: Boolean(agent.installed && state?.recentlyVerified),
capabilities: state?.capabilities || agent.capabilities || null,
available: Boolean(agent.installed && state?.invocable),
credentialState: agent.installed ? (state?.credentialState || 'unknown') : 'missing',
credentialState: agent.installed
? (state?.credentialState || agent.credentialState || 'unknown')
: 'missing',
availabilitySource: agent.installed ? (state?.availabilitySource || 'unverified') : 'none',
showInSidebar: Boolean(agent.installed && state?.showInSidebar),
attachmentTypes: Object.entries(support)
@@ -755,6 +769,7 @@ function registerIpc() {
getAttachmentStore: () => attachmentStore,
getAgentConnectors: () => agentConnectors,
getCloudAgentRuntime: () => cloudAgentRuntime,
getCloudAgentBridges: () => cloudAgentBridges,
getMainWindow: () => mainWindow,
getWorkspace: () => workspace,
installer,
@@ -797,6 +812,10 @@ if (!hasSingleInstanceLock) {
customAgentStore = new CustomAgentStore({
storagePath: path.join(app.getPath('userData'), 'meldwork-custom-agents.json'),
})
cloudAgentBridges = new CloudAgentBridgeStore({
storagePath: path.join(app.getPath('userData'), 'meldwork-cloud-agent-bridges.json'),
fetch: globalThis.fetch,
})
providerStore = new ProviderStore({
storagePath: path.join(app.getPath('userData'), 'meldwork-provider.json'),
safeStorage: lazySafeStorage,
@@ -871,6 +890,7 @@ if (!hasSingleInstanceLock) {
if (installRunning) installer.cancel(installerState.taskId)
quitCleanup = runQuitCleanup([
() => channelIngressRuntime?.shutdown(),
() => cloudAgentBridges?.close(),
() => cloudAgentRuntime?.shutdown(),
async () => {
try {
+6
View File
@@ -117,6 +117,12 @@ if (isLocalDocument) Object.assign(desktopApi, {
),
cancel: runId => ipcRenderer.invoke('local-cloud-agent:cancel', runId),
}),
cloudAgentBridge: Object.freeze({
list: () => ipcRenderer.invoke('local-cloud-agent-bridge:list'),
connect: input => ipcRenderer.invoke('local-cloud-agent-bridge:connect', input),
refresh: () => ipcRenderer.invoke('local-cloud-agent-bridge:refresh'),
delete: bridgeId => ipcRenderer.invoke('local-cloud-agent-bridge:delete', bridgeId),
}),
localOutcome: Object.freeze({
recordAdoption: input => ipcRenderer.invoke('local-outcome:record-adoption', input),
}),
+44
View File
@@ -172,6 +172,7 @@ function registerDesktopIpc(options) {
getAttachmentStore,
getAgentConnectors,
getCloudAgentRuntime,
getCloudAgentBridges,
getKnowledgeConnectors,
getMainWindow,
getOutcomeStore,
@@ -362,6 +363,44 @@ function registerDesktopIpc(options) {
if (!LOCAL_RUN_IDENTIFIER.test(normalizedRunId)) throw new Error('CLOUD_AGENT_RUN_INVALID')
return runtime.cancel(normalizedRunId)
})
registerTrustedHandle('local-cloud-agent-bridge:list', (...args) => {
if (args.length) throw new Error('CLOUD_AGENT_BRIDGE_REQUEST_INVALID')
const bridges = getCloudAgentBridges?.()
if (!bridges) throw new Error('CLOUD_AGENT_BRIDGE_RUNTIME_UNAVAILABLE')
return bridges.list()
})
registerTrustedHandle('local-cloud-agent-bridge:connect', async (...args) => {
if (args.length !== 1 || !args[0] || typeof args[0] !== 'object'
|| Array.isArray(args[0])
|| Reflect.ownKeys(args[0]).some(key => !['address', 'label'].includes(key))
|| typeof args[0].address !== 'string'
|| (args[0].label !== undefined && typeof args[0].label !== 'string')) {
throw new Error('CLOUD_AGENT_BRIDGE_REQUEST_INVALID')
}
const bridges = getCloudAgentBridges?.()
if (!bridges) throw new Error('CLOUD_AGENT_BRIDGE_RUNTIME_UNAVAILABLE')
const result = await bridges.connect(args[0])
await refreshLocalAgentState()
return result
})
registerTrustedHandle('local-cloud-agent-bridge:refresh', async (...args) => {
if (args.length) throw new Error('CLOUD_AGENT_BRIDGE_REQUEST_INVALID')
const bridges = getCloudAgentBridges?.()
if (!bridges) throw new Error('CLOUD_AGENT_BRIDGE_RUNTIME_UNAVAILABLE')
const result = await bridges.refresh()
await refreshLocalAgentState()
return result
})
registerTrustedHandle('local-cloud-agent-bridge:delete', async (...args) => {
if (args.length !== 1 || typeof args[0] !== 'string') {
throw new Error('CLOUD_AGENT_BRIDGE_REQUEST_INVALID')
}
const bridges = getCloudAgentBridges?.()
if (!bridges) throw new Error('CLOUD_AGENT_BRIDGE_RUNTIME_UNAVAILABLE')
const result = bridges.remove(args[0])
await refreshLocalAgentState()
return result
})
registerTrustedHandle('local-outcome:record-adoption', (...args) => {
if (args.length !== 1) throw new Error('LOCAL_ADOPTION_REQUEST_INVALID')
const outcomeStore = getOutcomeStore?.()
@@ -385,6 +424,11 @@ function registerDesktopIpc(options) {
if (customAgentStore.has(selectedKind)) {
return { supported: false, skills: [], total: 0, limit: 0 }
}
const cloudSkills = getCloudAgentBridges?.()?.skillsForInstance?.(selectedKind)
if (cloudSkills !== null && cloudSkills !== undefined) {
const skills = cloudSkills.map(skill => ({ ...skill, targetKind: selectedKind }))
return { supported: true, skills, total: skills.length, limit: 64 }
}
return installer.skills(selectedKind)
})
registerTrustedHandle('local-skill-trust:list', () => {
@@ -1,5 +1,6 @@
const { createHash, randomUUID } = require('node:crypto')
const {
agentRuntimeCapabilities,
isCodeReviewAgentKind,
requireTerminalAgentResult,
} = require('../agents/agent-runtime-contract.cjs')
@@ -600,9 +601,12 @@ class LocalWorkspaceAgentInvocation {
: (context.permissionMode === 'workspace-write' ? 'workspace-write' : 'read-only')
const writerKind = cleanText(context.singleWriterKind || context.writerKind, 80)
const writerAllowed = !writerKind || writerKind === kind
const supportsWorkspaceWrite = agentRuntimeCapabilities(kind, { agent })
.permissionModes.includes('workspace-write')
const allowWrite = group.allowWrite === true
&& requestedPermission === 'workspace-write'
&& writerAllowed
&& supportsWorkspaceWrite
&& !reviewOnly
&& !isolated
const skillInputTypes = new Set(['text'])
@@ -13,6 +13,7 @@ const AGENT_LABELS = {
opencodereview: 'OpenCodeReview',
}
const CUSTOM_AGENT_KIND = /^custom-[a-f0-9]{16}$/
const CLOUD_AGENT_KIND = /^cloud-[a-f0-9]{24}$/
const MAX_MESSAGE_TEXT_CHARS = 20000
const MAX_SYSTEM_PARAM_TEXT_CHARS = 1000
const STABLE_USER_TURNS_PER_EDGE = 3
@@ -23,7 +24,10 @@ const RECENT_TRANSCRIPT_TEXT_LIMIT = 9000
const SESSION_KEY = /^[A-Za-z0-9._:-]{1,240}$/
function isSupportedAgentKind(kind) {
return Object.hasOwn(AGENT_LABELS, kind) || CUSTOM_AGENT_KIND.test(String(kind || ''))
const normalized = String(kind || '')
return Object.hasOwn(AGENT_LABELS, normalized)
|| CUSTOM_AGENT_KIND.test(normalized)
|| CLOUD_AGENT_KIND.test(normalized)
}
function defaultAgentLabel(kind) {
@@ -45,6 +49,7 @@ function cleanInline(value, limit = 80) {
module.exports = {
AGENT_LABELS,
CLOUD_AGENT_KIND,
CUSTOM_AGENT_KIND,
MAX_MESSAGE_TEXT_CHARS,
MAX_SYSTEM_PARAM_TEXT_CHARS,
@@ -0,0 +1,113 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const net = require('node:net')
const { spawn } = require('node:child_process')
const {
discoverAgents,
invocationFor,
parentSessionDetached,
parseAgentOutput,
} = require('../../../src/agents/cloud/cloud-agent-bridge-server.cjs')
test('detects a Bridge parent process detached from its SSH session', () => {
assert.equal(parentSessionDetached(123, () => '123 (node) S 456 0 0 0'), false)
assert.equal(parentSessionDetached(123, () => '123 (node) S 1 0 0 0'), true)
assert.equal(parentSessionDetached(123, () => { throw new Error('gone') }), true)
})
test('starts the Bridge when the module is executed through node stdin', async () => {
const port = await new Promise((resolve, reject) => {
const server = net.createServer()
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
const selected = server.address().port
server.close(error => error ? reject(error) : resolve(selected))
})
})
const script = fs.readFileSync(
require.resolve('../../../src/agents/cloud/cloud-agent-bridge-server.cjs'),
'utf8',
)
const child = spawn(process.execPath, ['-'], {
env: { ...process.env, MELDWORK_AGENT_BRIDGE_PORT: String(port) },
stdio: ['pipe', 'pipe', 'pipe'],
})
try {
const ready = new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Bridge did not start')), 3000)
child.once('error', reject)
child.stdout.on('data', (chunk) => {
if (!String(chunk).includes(`MELDWORK_AGENT_BRIDGE_READY ${port}`)) return
clearTimeout(timer)
resolve(true)
})
})
child.stdin.end(script)
assert.equal(await ready, true)
} finally {
child.kill('SIGTERM')
}
})
test('discovers supported remote CLIs and keeps an unauthenticated CLI visible but unavailable', () => {
const outputs = new Map([
['codex --version', { status: 0, stdout: 'codex-cli 0.146.1\n' }],
['codex login status', { status: 0, stdout: 'Logged in using an API key\n' }],
['hermes --version', { status: 0, stdout: 'Hermes Agent v0.19.1 (2026.7.30)\n' }],
['claude --version', { status: 0, stdout: '2.1.123 (Claude Code)\n' }],
['claude auth status', { status: 0, stdout: '{"loggedIn":false,"authMethod":"none"}\n' }],
])
const agents = discoverAgents({
spawnSync: (command, args) => outputs.get(`${command} ${args.join(' ')}`)
|| { status: 127, stdout: '' },
listSkills: kind => kind === 'hermes'
? [{ namespace: 'hermes', slug: 'research', name: 'research' }]
: [],
})
assert.deepEqual(agents.map(agent => [agent.id, agent.version, agent.available]), [
['codex', '0.146.1', true],
['hermes', '0.19.1', true],
['claude', '2.1.123', false],
])
assert.deepEqual(agents[1].skills, [{ namespace: 'hermes', slug: 'research', name: 'research' }])
assert.equal(agents[2].credentialState, 'missing')
})
test('builds read-only invocations and parses Hermes and Claude output', () => {
assert.deepEqual(invocationFor('codex', 'hello', 'thread-1'), {
command: 'codex',
args: ['exec', 'resume', '--json', '--skip-git-repo-check', '--sandbox', 'read-only', 'thread-1', '-'],
input: 'hello',
})
assert.deepEqual(invocationFor('hermes', 'hello', ''), {
command: 'hermes',
args: ['chat', '--quiet', '--query', 'hello'],
input: '',
})
assert.deepEqual(invocationFor('claude', 'hello', 'session-1'), {
command: 'claude',
args: [
'--print', '--output-format', 'json', '--permission-mode', 'plan',
'--resume', 'session-1', 'hello',
],
input: '',
})
assert.deepEqual(parseAgentOutput('hermes', 'session_id: hermes-1\nremote answer\n'), {
text: 'remote answer', sessionRef: 'hermes-1', outcome: 'completed',
})
assert.deepEqual(parseAgentOutput('codex', [
JSON.stringify({ type: 'thread.started', thread_id: 'thread-1' }),
JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: 'codex answer' } }),
].join('\n')), {
text: 'codex answer', sessionRef: 'thread-1', outcome: 'completed',
})
assert.deepEqual(parseAgentOutput('claude', JSON.stringify({
type: 'result', subtype: 'success', is_error: false,
result: 'claude answer', session_id: 'claude-1',
})), {
text: 'claude answer', sessionRef: 'claude-1', outcome: 'completed',
})
})
@@ -0,0 +1,352 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const {
CloudAgentBridgeStore,
loginShellCommand,
manifestFor,
normalizeAddress,
} = require('../../../src/agents/cloud/cloud-agent-bridge.cjs')
test('runs fixed SSH bridge commands inside the remote login environment', () => {
assert.equal(loginShellCommand('exec node -'), "bash -lc 'exec node -'")
assert.equal(
loginShellCommand("printf '%s' ready"),
"bash -lc 'printf '\"'\"'%s'\"'\"' ready'",
)
})
test('keeps a cloud CLI request alive for the local Agent runtime timeout', () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-cloud-timeout-'))
try {
const store = new CloudAgentBridgeStore({
storagePath: path.join(directory, 'bridges.json'),
fetch: async () => response(manifest()),
})
assert.equal(store.timeoutMs, 3 * 60 * 1000)
} finally {
fs.rmSync(directory, { recursive: true, force: true })
}
})
function response(value, ok = true, status = 200) {
return {
ok,
status,
text: async () => JSON.stringify(value),
}
}
function manifest() {
return {
protocol: 'meldwork-agent-bridge',
version: 1,
server: { id: 'server-1', label: 'Research server' },
agents: [{
id: 'codex',
sourceKind: 'codex',
label: 'Codex CLI',
version: '0.1.0',
available: true,
credentialState: 'ready',
skills: [{ namespace: 'codex', slug: 'review', name: 'Review' }],
domains: ['general'],
inputTypes: ['text'],
permissionModes: ['read-only', 'workspace-write'],
}],
}
}
test('normalizes an IP HTTPS address and rejects non-IP destinations', () => {
assert.equal(normalizeAddress('https://203.0.113.10:8443/'), 'https://203.0.113.10:8443')
assert.equal(normalizeAddress('203.0.113.10:8443'), 'https://203.0.113.10:8443')
assert.throws(() => normalizeAddress('http://203.0.113.10:8443'), {
code: 'CLOUD_AGENT_BRIDGE_ADDRESS_INVALID',
})
assert.throws(() => normalizeAddress('https://agent.example.com'), {
code: 'CLOUD_AGENT_BRIDGE_ADDRESS_INVALID',
})
})
test('connects, persists, and exposes a bridge Agent as a connector entry', async () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-cloud-bridge-'))
const storagePath = path.join(directory, 'bridges.json')
const calls = []
const store = new CloudAgentBridgeStore({
storagePath,
fetch: async (url, options) => {
calls.push({ url, options })
if (options?.method === 'POST') return response({ text: 'remote reply', outcome: 'completed' })
return response(manifest())
},
})
try {
const connected = await store.connect({ address: 'https://203.0.113.10:8443' })
assert.equal(connected.label, 'Research server')
assert.equal(connected.available, true)
assert.deepEqual(connected.agents.map(agent => agent.id), ['codex'])
assert.equal(connected.agents[0].sourceKind, 'codex')
assert.equal(connected.agents[0].credentialState, 'ready')
assert.deepEqual(connected.agents[0].skills, [
{ namespace: 'codex', slug: 'review', name: 'Review' },
])
const [entry] = store.connectorEntries()
assert.equal(entry.agent.id, 'codex')
assert.equal(entry.manifest.transport.type, 'http')
assert.equal(entry.manifest.invocation.recipeId, 'external.cloud-agent-bridge')
assert.equal(entry.instance.bridgeId, connected.bridgeId)
assert.match(entry.manifest.manifestId, /^connector-manifest-[a-f0-9]{64}$/)
const result = await store.run({
bridgeId: connected.bridgeId,
agentId: 'codex',
prompt: 'hello',
permissionMode: 'read-only',
operationId: 'operation-1',
})
assert.deepEqual(result, { text: 'remote reply', outcome: 'completed' })
assert.equal(calls.at(-1).url, 'https://203.0.113.10:8443/v1/agents/codex/runs')
const restarted = new CloudAgentBridgeStore({
storagePath,
fetch: async () => response(manifest()),
})
assert.equal(restarted.list()[0].bridgeId, connected.bridgeId)
} finally {
fs.rmSync(directory, { recursive: true, force: true })
}
})
test('keeps installed but unavailable cloud Agents in the catalog and out of runtime entries', async () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-cloud-catalog-'))
const storagePath = path.join(directory, 'bridges.json')
const unavailableManifest = manifest()
unavailableManifest.agents.push({
id: 'claude', sourceKind: 'claude', label: 'Claude Code', version: '2.1.123',
available: false, credentialState: 'missing', skills: [], domains: ['general'],
inputTypes: ['text'], permissionModes: ['read-only'],
})
const store = new CloudAgentBridgeStore({
storagePath,
fetch: async () => response(unavailableManifest),
})
try {
await store.connect({ address: 'https://203.0.113.10:8443' })
assert.deepEqual(store.connectorEntries().map(entry => entry.agent.id), ['codex'])
assert.deepEqual(store.catalogEntries().map(agent => [agent.sourceKind, agent.available]), [
['codex', true],
['claude', false],
])
const claude = store.catalogEntries().find(agent => agent.sourceKind === 'claude')
assert.equal(claude.credentialState, 'missing')
assert.deepEqual(store.skillsForInstance(claude.kind), [])
const codex = store.catalogEntries().find(agent => agent.sourceKind === 'codex')
assert.deepEqual(store.skillsForInstance(codex.kind), [
{ namespace: 'codex', slug: 'review', name: 'Review' },
])
} finally {
fs.rmSync(directory, { recursive: true, force: true })
}
})
test('marks a persisted bridge unavailable after a failed refresh', async () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-cloud-bridge-'))
const storagePath = path.join(directory, 'bridges.json')
const store = new CloudAgentBridgeStore({
storagePath,
fetch: async () => response(manifest()),
})
try {
const connected = await store.connect({ address: 'https://198.51.100.12' })
store.fetch = async () => response({ error: 'offline' }, false, 503)
const [refreshed] = await store.refresh()
assert.equal(refreshed.bridgeId, connected.bridgeId)
assert.equal(refreshed.available, false)
assert.equal(refreshed.lastError, 'CLOUD_AGENT_BRIDGE_HTTP_503')
assert.deepEqual(store.connectorEntries(), [])
} finally {
fs.rmSync(directory, { recursive: true, force: true })
}
})
test('generates a valid manifest from a persisted bridge record', () => {
const manifestRecord = manifestFor({
bridgeId: 'cloud-bridge-0123456789abcdef01234567',
address: 'https://203.0.113.10:8443',
label: 'Server',
}, {
id: 'codex', label: 'Codex', version: '1.0.0', domains: ['general'],
inputTypes: ['text'], permissionModes: ['read-only'],
session: { supported: true, resume: true, cancel: true, checkpoint: false },
})
assert.equal(manifestRecord.connectorVersion, '1.0.0')
assert.deepEqual(manifestRecord.outboundDestinations, ['https://203.0.113.10:8443'])
})
test('discovers and runs a remote Codex CLI through existing SSH configuration', async () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-cloud-ssh-'))
const storagePath = path.join(directory, 'bridges.json')
const calls = []
const store = new CloudAgentBridgeStore({
storagePath,
fetch: async () => response(manifest()),
sshTunnelStart: async () => { throw new Error('tunnel mocked unavailable') },
sshExecute: async input => {
calls.push(input)
if (input.command.includes('codex --version')) return { stdout: 'codex-cli 0.121.0\n', stderr: '' }
return {
stdout: [
JSON.stringify({ type: 'thread.started', thread_id: 'remote-thread-1' }),
JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: 'remote reply' } }),
JSON.stringify({ type: 'turn.completed' }),
].join('\n'),
stderr: '',
}
},
})
try {
const connected = await store.connect({ address: '10.1.132.21' })
assert.equal(connected.transport, 'ssh')
assert.equal(connected.address, '10.1.132.21')
assert.deepEqual(connected.agents.map(agent => agent.label), ['Codex CLI'])
const [entry] = store.connectorEntries()
assert.equal(entry.manifest.invocation.recipeId, 'external.cloud-agent-ssh')
assert.deepEqual(entry.manifest.permissionModes, ['read-only'])
assert.deepEqual(entry.manifest.outboundDestinations, [])
const result = await store.run({
bridgeId: connected.bridgeId,
agentId: 'codex',
prompt: 'hello remotely',
permissionMode: 'read-only',
})
assert.deepEqual(result, { text: 'remote reply', sessionRef: '', outcome: 'completed' })
assert.equal(calls[0].command.includes('codex --version'), true)
assert.equal(calls[1].command, 'exec codex exec --json --skip-git-repo-check --sandbox read-only -')
assert.equal(calls[1].input, 'hello remotely')
} finally {
fs.rmSync(directory, { recursive: true, force: true })
}
})
test('upgrades a persisted direct SSH connection to the enhanced tunnel without changing its id', async () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-cloud-ssh-upgrade-'))
const storagePath = path.join(directory, 'bridges.json')
const store = new CloudAgentBridgeStore({
storagePath,
fetch: async () => response(manifest()),
sshTunnelStart: async () => { throw new Error('initial tunnel unavailable') },
sshExecute: async () => ({ stdout: 'codex-cli 0.121.0\n', stderr: '' }),
})
try {
const connected = await store.connect({ address: '10.1.132.21' })
assert.equal(connected.transport, 'ssh')
store.sshTunnelStart = async () => ({
child: { killed: false, kill() { this.killed = true }, once() {} },
endpoint: 'http://127.0.0.1:45678',
})
const [upgraded] = await store.refresh()
assert.equal(upgraded.bridgeId, connected.bridgeId)
assert.equal(upgraded.transport, 'ssh-tunnel')
assert.equal(upgraded.agents[0].sourceKind, 'codex')
} finally {
store.close()
fs.rmSync(directory, { recursive: true, force: true })
}
})
test('keeps an SSH discovery failure when the HTTPS fallback is unavailable', async () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-cloud-ssh-failure-'))
const store = new CloudAgentBridgeStore({
storagePath: path.join(directory, 'bridges.json'),
fetch: async () => { throw new Error('network unavailable') },
sshTunnelStart: async () => { throw new Error('tunnel mocked unavailable') },
sshExecute: async () => ({ stdout: '', stderr: '' }),
})
try {
await assert.rejects(store.connect({ address: '10.1.132.21' }), {
code: 'CLOUD_AGENT_SSH_NO_AGENT',
})
} finally {
fs.rmSync(directory, { recursive: true, force: true })
}
})
test('connects through an SSH-local Agent Bridge tunnel and reuses its HTTP protocol', async () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-cloud-tunnel-'))
const storagePath = path.join(directory, 'bridges.json')
const calls = []
const child = { killed: false, kill() { this.killed = true } }
const store = new CloudAgentBridgeStore({
storagePath,
fetch: async (url, options) => {
calls.push({ url, options })
if (options?.method === 'POST') return response({ text: 'tunneled reply', outcome: 'completed' })
return response(manifest())
},
sshTunnelStart: async () => ({ child, endpoint: 'http://127.0.0.1:45678' }),
})
try {
const connected = await store.connect({ address: '10.1.132.21', label: 'SSH tunnel server' })
assert.equal(connected.transport, 'ssh-tunnel')
assert.equal(connected.address, '10.1.132.21')
const result = await store.run({
bridgeId: connected.bridgeId,
agentId: 'codex',
prompt: 'through tunnel',
permissionMode: 'read-only',
operationId: 'tunnel-operation-1',
})
assert.deepEqual(result, { text: 'tunneled reply', outcome: 'completed' })
assert.equal(calls.at(-1).url, 'http://127.0.0.1:45678/v1/agents/codex/runs')
store.remove(connected.bridgeId)
assert.equal(child.killed, true)
} finally {
fs.rmSync(directory, { recursive: true, force: true })
}
})
test('passes a caller abort signal through to cloud Agent execution', async () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-cloud-bridge-'))
const storagePath = path.join(directory, 'bridges.json')
const controller = new AbortController()
let receivedSignal = null
let resolveRun = null
const store = new CloudAgentBridgeStore({
storagePath,
fetch: async (url, options) => {
receivedSignal = options.signal
if (options.method === 'POST') {
return new Promise(resolve => { resolveRun = () => resolve(response({ text: 'done', outcome: 'completed' })) })
}
return response(manifest())
},
})
try {
const connected = await store.connect({ address: 'https://203.0.113.10:8443' })
const runPromise = store.run({
bridgeId: connected.bridgeId,
agentId: 'codex',
prompt: 'cancel me',
permissionMode: 'read-only',
operationId: 'operation-2',
signal: controller.signal,
})
await new Promise(resolve => setImmediate(resolve))
assert.notEqual(receivedSignal, controller.signal)
assert.equal(receivedSignal.aborted, false)
controller.abort()
assert.equal(receivedSignal.aborted, true)
resolveRun()
await runPromise
} finally {
fs.rmSync(directory, { recursive: true, force: true })
}
})
@@ -5,6 +5,7 @@ const path = require('node:path')
const test = require('node:test')
const { createAgentConnectorManifest, serializeAgentConnectorManifest } = require('../../../src/agents/connectors/agent-connector-manifest.cjs')
const { manifestFor } = require('../../../src/agents/cloud/cloud-agent-bridge.cjs')
const { AgentConnectorInstanceStore } = require('../../../src/agents/connectors/agent-connector-instance-store.cjs')
const { isSupportedAgentKind } = require('../../../src/workspace/local-workspace-contracts.cjs')
const {
@@ -54,6 +55,7 @@ function fixture(t, options = {}) {
}
},
seedSample: options.seedSample,
cloudBridges: options.cloudBridges,
})
return {
calls,
@@ -118,6 +120,106 @@ test('discovers an explicitly installed approved sample and exposes generic sani
assert.equal(calls[0][3].sandbox, 'read-only')
})
test('discovers and runs an Agent exposed by a Cloud Agent Bridge', async (t) => {
const record = {
bridgeId: 'cloud-bridge-0123456789abcdef01234567',
address: 'https://203.0.113.10:8443',
label: 'Research server',
available: true,
}
const agent = {
id: 'codex', sourceKind: 'codex', label: 'Codex CLI', version: '0.146.1', description: '',
credentialState: 'ready',
domains: ['general'], inputTypes: ['text'], permissionModes: ['read-only'],
session: { supported: true, resume: true, cancel: true, checkpoint: false },
}
const manifest = manifestFor(record, agent)
const entry = {
record, agent, manifest,
instance: {
instanceId: manifest.connectorId,
connectorId: manifest.connectorId,
connectorVersion: manifest.connectorVersion,
upstreamVersion: '1.0.0',
label: manifest.label,
credentialRef: null,
manifestId: manifest.manifestId,
bridgeId: record.bridgeId,
agentId: agent.id,
},
}
let cloudRunInput
const { connectors } = fixture(t, {
cloudBridges: {
connectorEntries: () => [entry],
bridgeForInstance: instanceId => instanceId === entry.instance.instanceId ? entry : null,
run: async (input) => {
cloudRunInput = input
return { text: 'cloud reply', outcome: 'completed' }
},
},
})
const [detected] = connectors.refresh([])
assert.equal(detected.cloud, true)
assert.equal(isSupportedAgentKind(detected.kind), true)
const cloudCatalog = connectors.catalog()[0]
assert.equal(cloudCatalog.cloud, true)
assert.equal(cloudCatalog.custom, false)
assert.equal(cloudCatalog.sourceKind, 'codex')
assert.equal(cloudCatalog.version, '0.146.1')
assert.equal(cloudCatalog.credentialState, 'ready')
const result = await connectors.run(detected, 'hello cloud', '/tmp/workspace', {
runId: 'run-cloud-1', agentRunId: 'agent-cloud-1', operationId: 'operation-cloud-1', sandbox: 'read-only',
})
assert.equal(result.text, 'cloud reply')
assert.equal(result.outcome, 'completed')
assert.equal(cloudRunInput.permissionMode, 'read-only')
assert.deepEqual(result.connectorEventState.events.map(event => event.type), ['Completed'])
})
test('runs a Codex Agent discovered through SSH without declaring an HTTP outbound destination', async (t) => {
const record = {
bridgeId: 'cloud-bridge-0123456789abcdef01234567',
transport: 'ssh',
address: '10.1.132.21',
label: 'Research server',
available: true,
}
const agent = {
id: 'codex', label: 'Codex CLI', version: '0.121.0', description: '',
domains: ['general'], inputTypes: ['text'], permissionModes: ['read-only'],
session: { supported: false, resume: false, cancel: true, checkpoint: false },
}
const manifest = manifestFor(record, agent)
const entry = {
record, agent, manifest,
instance: {
instanceId: manifest.connectorId,
connectorId: manifest.connectorId,
connectorVersion: manifest.connectorVersion,
upstreamVersion: '1.0.0',
label: manifest.label,
credentialRef: null,
manifestId: manifest.manifestId,
bridgeId: record.bridgeId,
agentId: agent.id,
},
}
const { connectors } = fixture(t, {
cloudBridges: {
connectorEntries: () => [entry],
bridgeForInstance: instanceId => instanceId === entry.instance.instanceId ? entry : null,
run: async () => ({ text: 'SSH reply', outcome: 'completed' }),
},
})
const [detected] = connectors.refresh([])
const result = await connectors.run(detected, 'hello SSH', '/tmp/workspace', {
runId: 'run-cloud-ssh-1', agentRunId: 'agent-cloud-ssh-1', operationId: 'operation-cloud-ssh-1', sandbox: 'read-only',
})
assert.equal(result.text, 'SSH reply')
assert.deepEqual(result.connectorEventState.events.map(event => event.type), ['Completed'])
})
test('delegated local Connector receives a streaming profile event before completion', async (t) => {
const releaseFile = path.join(os.tmpdir(), `meldwork-connector-release-${process.pid}-${Date.now()}`)
t.after(async () => {
@@ -273,6 +273,10 @@ test('ready activates one fixed local workspace and loads the bundled frontend',
'local-cloud-agent:provide-input',
'local-cloud-agent:decide-permission',
'local-cloud-agent:cancel',
'local-cloud-agent-bridge:list',
'local-cloud-agent-bridge:connect',
'local-cloud-agent-bridge:refresh',
'local-cloud-agent-bridge:delete',
],
)
assert.equal([...harness.ipcHandlers.keys()].some(name => (
@@ -1721,6 +1725,56 @@ test('Skills IPC uses the installed-Agent gate and the shared local catalog', as
assert.deepEqual(harness.skillCatalogInstances[0].listCalls, ['codex'])
})
test('Cloud Agent catalog and Skills IPC preserve remote metadata without exposing paths', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-cloud-skills-ipc-'))
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const kind = 'cloud-0123456789abcdef01234567'
class TestCloudAgentBridgeStore {
refresh() { return Promise.resolve([]) }
connectorEntries() { return [] }
catalogEntries() {
return [{
kind, sourceKind: 'claude', label: 'Claude Code @ Research server',
description: 'Remote Claude Code', version: '2.1.123', installed: true,
installSupported: false, providerMode: 'connector', custom: false,
connector: true, cloud: true, available: false, credentialState: 'missing',
serverLabel: 'Research server',
}]
}
skillsForInstance(instanceId) {
return instanceId === kind
? [{ namespace: 'claude', slug: 'research', name: 'Research' }]
: null
}
close() {}
}
const { harness } = loadMain(directory, {
moduleMocks: {
'./agents/cloud/cloud-agent-bridge.cjs': { CloudAgentBridgeStore: TestCloudAgentBridgeStore },
},
})
await harness.ready()
const catalog = await harness.ipcHandlers.get('local-agent-installer:catalog')(harness.event())
const cloudAgent = catalog.agents.find(agent => agent.kind === kind)
assert.equal(cloudAgent.sourceKind, 'claude')
assert.equal(cloudAgent.version, '2.1.123')
assert.equal(cloudAgent.credentialState, 'missing')
assert.equal(cloudAgent.available, false)
assert.equal(cloudAgent.custom, false)
assert.equal('executable' in cloudAgent, false)
assert.deepEqual(
await harness.ipcHandlers.get('local-agent-installer:skills')(harness.event(), kind),
{
supported: true,
total: 1,
limit: 64,
skills: [{ targetKind: kind, namespace: 'claude', slug: 'research', name: 'Research' }],
},
)
})
test('attachment IPC returns bounded metadata, opens private files, and never exposes paths', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-image-ipc-'))
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
@@ -62,12 +62,17 @@ test('local preload exposes the local-only Meldwork API and narrow Provider meth
assert.equal(Object.isFrozen(api.localAgentConnector), true)
assert.equal(Object.isFrozen(api.localSkillTrust), true)
assert.equal(Object.isFrozen(api.cloudAgent), true)
assert.equal(Object.isFrozen(api.cloudAgentBridge), true)
assert.equal(Object.isFrozen(api.localOutcome), true)
assert.deepEqual(Object.keys(api.localOutcome), ['recordAdoption'])
assert.deepEqual(
Object.keys(api.cloudAgent).sort(),
['cancel', 'decidePermission', 'provideInput'],
)
assert.deepEqual(
Object.keys(api.cloudAgentBridge).sort(),
['connect', 'delete', 'list', 'refresh'],
)
assert.deepEqual(
Object.keys(api.localWorkspace).sort(),
[
@@ -38,6 +38,10 @@ const LOCAL_IPC_CHANNELS = Object.freeze([
'local-cloud-agent:provide-input',
'local-cloud-agent:decide-permission',
'local-cloud-agent:cancel',
'local-cloud-agent-bridge:list',
'local-cloud-agent-bridge:connect',
'local-cloud-agent-bridge:refresh',
'local-cloud-agent-bridge:delete',
'local-outcome:record-adoption',
'local-workspace:pick-directory',
'local-workspace:default-directory',
@@ -3,6 +3,9 @@ const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const { getEventListeners } = require('node:events')
const { manifestFor } = require('../../src/agents/cloud/cloud-agent-bridge.cjs')
const { AgentConnectorInstanceStore } = require('../../src/agents/connectors/agent-connector-instance-store.cjs')
const { LocalAgentConnectors } = require('../../src/agents/connectors/agent-connector-local.cjs')
const { createAgentConnectorManifest } = require('../../src/agents/connectors/agent-connector-manifest.cjs')
const { AgentConnectorRegistry } = require('../../src/agents/connectors/agent-connector-registry.cjs')
const { AgentConnectorRuntime } = require('../../src/agents/connectors/agent-connector-runtime.cjs')
@@ -14,6 +17,14 @@ const { RunScheduler } = require('../../src/runs/run-scheduler.cjs')
const { deferred, fixture } = require('../support/local-workspace-test-helpers.cjs')
const { executable } = require('../support/cli-adapters-test-helpers.cjs')
function testSafeStorage() {
return {
isEncryptionAvailable: () => true,
encryptString: value => Buffer.from(`sealed:${value}`, 'utf8'),
decryptString: value => Buffer.from(value).toString('utf8').slice('sealed:'.length),
}
}
function inputConnectorRuntime(handler) {
const manifest = createAgentConnectorManifest({
connectorId: 'external.input-agent',
@@ -102,6 +113,108 @@ process.stdout.write(JSON.stringify({ type: 'turn.completed' }) + '\\n')
assert.doesNotMatch(publicEvents.map(event => event.delta).join(''), new RegExp(secret))
})
test('Cloud Bridge Agents can create direct and group chats through the Connector runtime', async (t) => {
const { directory, options } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const record = {
bridgeId: 'cloud-bridge-0123456789abcdef01234567',
transport: 'ssh-tunnel',
address: '10.1.132.21',
endpoint: 'http://127.0.0.1:45678',
label: 'Cloud server',
available: true,
}
const agents = ['codex', 'hermes'].map(id => ({
id,
sourceKind: id,
label: id === 'codex' ? 'Codex' : 'Hermes',
version: '1.0.0',
description: '',
available: true,
credentialState: 'ready',
domains: ['general'],
inputTypes: ['text'],
permissionModes: ['read-only'],
session: { supported: true, resume: true, cancel: true, checkpoint: false },
}))
const entries = agents.map((agent) => {
const manifest = manifestFor(record, agent)
return {
record,
agent,
manifest,
instance: {
instanceId: manifest.connectorId,
connectorId: manifest.connectorId,
connectorVersion: manifest.connectorVersion,
upstreamVersion: '1.0.0',
label: manifest.label,
credentialRef: null,
manifestId: manifest.manifestId,
bridgeId: record.bridgeId,
agentId: agent.id,
},
}
})
const cloudRuns = []
const cloudBridges = {
connectorEntries: () => entries,
bridgeForInstance: instanceId => entries.find(entry => entry.instance.instanceId === instanceId) || null,
run: async (input) => {
cloudRuns.push(input)
return { text: `${input.agentId} cloud reply`, outcome: 'completed' }
},
}
const connectors = new LocalAgentConnectors({
manifestDirectory: path.join(directory, 'agent-connectors'),
instanceStore: new AgentConnectorInstanceStore({
instanceStoragePath: path.join(directory, 'connector-instances.json'),
credentialStoragePath: path.join(directory, 'private', 'connector-credentials.json'),
safeStorage: testSafeStorage(),
}),
runAgent: async () => { throw new Error('LOCAL_RUNNER_MUST_NOT_RUN') },
cloudBridges,
})
options.detectAgents = async () => connectors.refresh([])
options.connectorRuntime = connectors
options.runAgent = async () => { throw new Error('LOCAL_RUNNER_MUST_NOT_RUN') }
const workspace = new LocalWorkspace(options)
const snapshot = await workspace.refreshAgents()
const codexKind = entries.find(entry => entry.agent.id === 'codex').instance.instanceId
const hermesKind = entries.find(entry => entry.agent.id === 'hermes').instance.instanceId
assert.deepEqual(snapshot.agents.map(agent => agent.kind).sort(), [codexKind, hermesKind].sort())
const direct = workspace.createGroup({
conversationType: 'direct', directAgentKind: codexKind, workdir: directory,
})
await workspace.sendMessage({ groupId: direct.id, text: 'Direct cloud request' })
const group = workspace.createGroup({
name: 'Cloud collaboration', agentKinds: [codexKind, hermesKind], workdir: directory,
})
await workspace.sendMessage({
groupId: group.id,
text: 'Group cloud request',
targetKinds: [codexKind, hermesKind],
})
assert.deepEqual(
cloudRuns.map(run => run.agentId),
['codex', 'codex', 'hermes'],
JSON.stringify(workspace.snapshot().messages),
)
assert.equal(cloudRuns.every(run => run.permissionMode === 'read-only'), true)
assert.deepEqual(
workspace.snapshot().messages.filter(message => message.role === 'agent')
.map(message => [message.groupId, message.agentKind, message.content]),
[
[direct.id, codexKind, 'codex cloud reply'],
[group.id, codexKind, 'codex cloud reply'],
[group.id, hermesKind, 'hermes cloud reply'],
],
)
})
test('Harness streams per-Agent events, persists a compact trace, and hands evidence to the next Agent', async (t) => {
const { directory, calls, options } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
+10 -1
View File
@@ -197,7 +197,7 @@ import RunTracePanel from './components/RunTracePanel.vue'
import SystemSettingsView from './components/SystemSettingsView.vue'
import WorkspaceModalContent from './components/WorkspaceModalContent.vue'
import WorkspaceSidebar from './components/WorkspaceSidebar.vue'
import { agentLabel, setCustomAgentProfiles } from './catalog.js'
import { agentLabel, setCloudAgentProfiles, setCustomAgentProfiles } from './catalog.js'
import { createConversationControllers } from './conversationControllers.js'
import { useAgentCatalog } from './composables/useAgentCatalog.js'
import { useAgentManagement } from './composables/useAgentManagement.js'
@@ -219,6 +219,7 @@ import { useConversationViewport } from './composables/useConversationViewport.j
import { useDesktopWorkspaceLifecycle } from './composables/useDesktopWorkspaceLifecycle.js'
import { useEmptyShowcasePlayback } from './composables/useEmptyShowcasePlayback.js'
import { useAppWindowInteractions } from './composables/useAppWindowInteractions.js'
import { useCloudAgentBridges } from './composables/useCloudAgentBridges.js'
import { useKnowledgeBaseSettings } from './composables/useKnowledgeBaseSettings.js'
import { useMessageActions } from './composables/useMessageActions.js'
import { useOnboarding } from './composables/useOnboarding.js'
@@ -298,6 +299,7 @@ const installer = computed(() => api.value?.agentInstaller || null)
const customAgent = computed(() => api.value?.customAgent || null)
const provider = computed(() => api.value?.localAgentProvider || null)
const knowledgeBase = computed(() => api.value?.localKnowledgeBase || null)
const cloudAgentBridge = computed(() => api.value?.cloudAgentBridge || null)
const attachmentsApi = computed(() => api.value?.localAttachments || null)
const {
clearOnboardingPlayback,
@@ -438,6 +440,11 @@ const agentRefresh = useAgentRefresh({
workspace,
})
const { refreshAgents } = agentRefresh
const cloudAgentBridges = useCloudAgentBridges({
bridgeApi: cloudAgentBridge,
refreshAgents,
showError,
})
const agentManagement = useAgentManagement({
activeView,
closeModal,
@@ -855,6 +862,7 @@ const {
activeGroup,
activeView,
closeModal,
cloudAgentBridges,
confirmUnlimitedRounds,
contentInteractionBlocked,
customAgentDeleteArmed,
@@ -942,6 +950,7 @@ const { booting, bridgeMissing } = useDesktopWorkspaceLifecycle({
beforeBoot: () => {
applyTheme(theme.value)
setCustomAgentProfiles([])
setCloudAgentProfiles([])
},
defaultDirectory,
handleOpenGroup,
@@ -182,7 +182,7 @@ describe('Meldwork workbench', () => {
})
await wrapper.get('.conversation-link').trigger('click')
expect(wrapper.get('.mode-segmented [data-mode="manual"]').text()).toBe('Concurrent responses')
expect(wrapper.get('.mode-segmented [data-mode="manual"]').text()).toBe('Concurrent replies')
const styles = readFileSync(resolve(process.cwd(), 'src/styles.css'), 'utf8')
expect(styles).toMatch(/@container \(max-width: 420px\)[\s\S]*\.mode-segmented button\s*\{[^}]*white-space:\s*normal;/s)
expect(styles).toMatch(/@media \(prefers-reduced-motion: reduce\)[\s\S]*\.composer-box\.unlimited-running \.round-unlimited-symbol,[\s\S]*animation:\s*none;/s)
@@ -192,7 +192,44 @@ describe('Meldwork workbench', () => {
await wrapper.get('.mode-segmented [data-mode="manual"]').trigger('click')
await wrapper.findAll('.target-chip')[1].trigger('click')
expect(wrapper.get('.mode-segmented [data-mode="manual"]').text()).toBe('单轮回答')
expect(wrapper.get('.mode-segmented [data-mode="manual"]').text()).toBe('单次回答')
wrapper.unmount()
})
it('shows the Auto discussion Beta mode with an explanation placeholder and a coming-soon guard', async () => {
const { wrapper, bridge } = await mountApp(({ state }) => {
state.groups.push({
id: 'group-auto-beta',
conversationType: 'group',
name: 'Beta group',
topic: '',
agentKinds: ['codex', 'hermes'],
workdir: '/tmp/meldwork-workspace',
allowWrite: false,
createdAt: '2026-07-29T08:00:00Z',
updatedAt: '2026-07-29T08:00:00Z',
})
})
await wrapper.get('.conversation-link').trigger('click')
const betaButton = wrapper.get('.mode-segmented [data-mode="auto-beta"]')
expect(betaButton.attributes()).not.toHaveProperty('disabled')
expect(betaButton.text()).toContain('Auto discussion')
expect(betaButton.get('.mode-beta-badge').text()).toBe('Beta')
expect(betaButton.find('.mode-beta-icon').exists()).toBe(false)
await betaButton.trigger('click')
expect(betaButton.classes()).toContain('active')
expect(wrapper.get('.composer-box textarea').attributes('placeholder'))
.toBe('Beta: Agents @ each other into the discussion and the model orchestrates the rounds autonomously.')
await wrapper.get('.composer-box textarea').setValue('Plan a launch')
await wrapper.get('.send-button').trigger('click')
await flushPromises()
expect(bridge.localWorkspace.send).not.toHaveBeenCalled()
expect(wrapper.get('.toast-message').text()).toContain('coming soon')
wrapper.unmount()
})
@@ -44,6 +44,19 @@ afterEach(() => {
})
describe('Meldwork workbench', () => {
it('provides a dedicated cloud-server settings tab', async () => {
const { wrapper } = await mountApp()
await wrapper.get('.sidebar-settings-entry').trigger('click')
const cloudTab = wrapper.findAll('.settings-tabs button')
.find(button => button.text().includes('Cloud servers'))
await cloudTab.trigger('click')
expect(wrapper.get('.cloud-agent-bridge-panel h2').text()).toBe('Cloud servers')
expect(wrapper.get('.cloud-agent-connect-form').exists()).toBe(true)
wrapper.unmount()
})
it('enables onboarding completion as soon as Agent detection finishes on any slide', async () => {
vi.useFakeTimers()
localStorage.removeItem('meldwork-onboarding-seen-v1')
@@ -207,7 +220,7 @@ describe('Meldwork workbench', () => {
expect(bridge.localAgentProvider.status).toHaveBeenCalledTimes(AGENTS.length)
expect(bridge.localKnowledgeBase.status).toHaveBeenCalledTimes(1)
await wrapper.findAll('.settings-tabs button')[1].trigger('click')
await wrapper.findAll(".settings-tabs button")[2].trigger("click")
await flushPromises()
expect(bridge.localAgentProvider.status).toHaveBeenCalledTimes((AGENTS.length * 2) - 1)
expect(bridge.localAgentProvider.probe).toHaveBeenCalledTimes(1)
@@ -219,7 +232,7 @@ describe('Meldwork workbench', () => {
await flushPromises()
expect(bridge.localKnowledgeBase.status).toHaveBeenCalledTimes(1)
await wrapper.findAll('.settings-tabs button')[2].trigger('click')
await wrapper.findAll(".settings-tabs button")[3].trigger("click")
await flushPromises()
expect(bridge.localKnowledgeBase.status).toHaveBeenCalledTimes(2)
expect(wrapper.get('.knowledge-base-ready-summary').text()).toContain('1 ready, 3 total')
@@ -244,7 +257,7 @@ describe('Meldwork workbench', () => {
const { wrapper } = await mountApp()
await wrapper.get('.sidebar-settings-entry').trigger('click')
await wrapper.findAll('.settings-tabs button')[1].trigger('click')
await wrapper.findAll(".settings-tabs button")[2].trigger("click")
await flushPromises()
expect(wrapper.findAll('.provider-agent-list button')).toHaveLength(AGENTS.length)
@@ -343,7 +356,7 @@ describe('Meldwork workbench', () => {
})
await wrapper.get('.sidebar-settings-entry').trigger('click')
await wrapper.findAll('.settings-tabs button')[1].trigger('click')
await wrapper.findAll(".settings-tabs button")[2].trigger("click")
await flushPromises()
const providerButton = label => wrapper.findAll('.provider-agent-list button')
.find(button => button.text().includes(label))
@@ -368,7 +381,7 @@ describe('Meldwork workbench', () => {
})
await wrapper.get('.sidebar-settings-entry').trigger('click')
await wrapper.findAll('.settings-tabs button')[1].trigger('click')
await wrapper.findAll(".settings-tabs button")[2].trigger("click")
await flushPromises()
const codexProvider = wrapper.findAll('.provider-agent-list button')
@@ -413,7 +426,7 @@ describe('Meldwork workbench', () => {
})
await wrapper.get('.sidebar-settings-entry').trigger('click')
await wrapper.findAll('.settings-tabs button')[1].trigger('click')
await wrapper.findAll(".settings-tabs button")[2].trigger("click")
await wrapper.vm.$nextTick()
const kimiProvider = wrapper.findAll('.provider-agent-list button')
@@ -455,7 +468,7 @@ describe('Meldwork workbench', () => {
})
await wrapper.get('.sidebar-settings-entry').trigger('click')
await wrapper.findAll('.settings-tabs button')[1].trigger('click')
await wrapper.findAll(".settings-tabs button")[2].trigger("click")
await flushPromises()
const codexProvider = wrapper.findAll('.provider-agent-list button')
@@ -526,7 +539,7 @@ describe('Meldwork workbench', () => {
})
await wrapper.get('.sidebar-settings-entry').trigger('click')
await wrapper.findAll('.settings-tabs button')[1].trigger('click')
await wrapper.findAll(".settings-tabs button")[2].trigger("click")
await wrapper.findAll('.provider-agent-list button')
.find(button => button.text().includes('Kimi Code'))
.trigger('click')
@@ -592,7 +605,7 @@ describe('Meldwork workbench', () => {
})
await wrapper.get('.sidebar-settings-entry').trigger('click')
await wrapper.findAll('.settings-tabs button')[1].trigger('click')
await wrapper.findAll(".settings-tabs button")[2].trigger("click")
await wrapper.findAll('.provider-agent-list button')
.find(button => button.text().includes('Hermes'))
.trigger('click')
@@ -655,7 +668,7 @@ describe('Meldwork workbench', () => {
})
await wrapper.get('.sidebar-settings-entry').trigger('click')
await wrapper.findAll('.settings-tabs button')[1].trigger('click')
await wrapper.findAll(".settings-tabs button")[2].trigger("click")
const hermesProvider = wrapper.findAll('.provider-agent-list button')
.find(button => button.text().includes('Hermes'))
await hermesProvider.trigger('click')
@@ -689,7 +702,7 @@ describe('Meldwork workbench', () => {
})
await wrapper.get('.sidebar-settings-entry').trigger('click')
await wrapper.findAll('.settings-tabs button')[1].trigger('click')
await wrapper.findAll(".settings-tabs button")[2].trigger("click")
await wrapper.findAll('.provider-agent-list button')
.find(button => button.text().includes('Hermes'))
.trigger('click')
@@ -722,7 +735,7 @@ describe('Meldwork workbench', () => {
const { wrapper, bridge } = await mountApp()
await wrapper.get('.sidebar-settings-entry').trigger('click')
await wrapper.findAll('.settings-tabs button')[2].trigger('click')
await wrapper.findAll(".settings-tabs button")[3].trigger("click")
await wrapper.vm.selectSystemSettingsSection('knowledge-bases')
await flushPromises()
@@ -814,7 +827,7 @@ describe('Meldwork workbench', () => {
})
await wrapper.get('.sidebar-settings-entry').trigger('click')
await wrapper.findAll('.settings-tabs button')[2].trigger('click')
await wrapper.findAll(".settings-tabs button")[3].trigger("click")
await flushPromises()
expect(wrapper.get('.knowledge-base-ready-summary').text()).toContain('2 ready, 3 total')
@@ -838,7 +851,7 @@ describe('Meldwork workbench', () => {
})
await wrapper.get('.sidebar-settings-entry').trigger('click')
await wrapper.findAll('.settings-tabs button')[2].trigger('click')
await wrapper.findAll(".settings-tabs button")[3].trigger("click")
await wrapper.vm.$nextTick()
const localItems = wrapper.findAll('.knowledge-base-item')
@@ -895,7 +908,7 @@ describe('Meldwork workbench', () => {
})
await wrapper.get('.sidebar-settings-entry').trigger('click')
await wrapper.findAll('.settings-tabs button')[2].trigger('click')
await wrapper.findAll(".settings-tabs button")[3].trigger("click")
await flushPromises()
const feishuCard = wrapper.findAll('.knowledge-base-item')
+65 -4
View File
@@ -146,6 +146,67 @@ describe('Meldwork workbench', () => {
wrapper.unmount()
})
it('renders cloud Agents with product logos, cloud markers, version, and remote Skills', async () => {
const cloudKind = 'cloud-0123456789abcdef01234567'
const cloudAgent = {
kind: cloudKind,
sourceKind: 'codex',
label: 'Codex @ Research server',
description: 'Remote Codex CLI',
version: '0.146.1',
installed: true,
available: true,
credentialState: 'ready',
showInSidebar: true,
providerMode: 'connector',
custom: false,
connector: true,
cloud: true,
serverLabel: 'Research server',
}
const { wrapper, bridge } = await mountApp(({ state, bridge: desktopBridge }) => {
state.agents.push(cloudAgent)
desktopBridge.agentInstaller.catalog.mockResolvedValue({
platform: 'darwin',
agents: [
...AGENTS.map(agent => ({ kind: agent.kind, installed: true, installSupported: true })),
cloudAgent,
],
})
desktopBridge.agentInstaller.skills.mockResolvedValue({
supported: true,
total: 1,
skills: [{ targetKind: cloudKind, namespace: 'codex', slug: 'review', name: 'Review' }],
})
})
const sidebarCloud = wrapper.findAll('.sidebar-agent')
.find(agent => agent.text().includes('Codex @ Research server'))
expect(sidebarCloud.get('.agent-logo-cloud.cloud').exists()).toBe(true)
expect(sidebarCloud.get('img').attributes('src')).toContain('agent-logos/codex.svg')
await wrapper.get('.new-group-button').trigger('click')
const cloudChoice = wrapper.findAll('.agent-choice')
.find(choice => choice.text().includes('Codex @ Research server'))
expect(cloudChoice.get('.agent-choice-logo.cloud').exists()).toBe(true)
await wrapper.get('.modal-header .icon-button').trigger('click')
await wrapper.get('.sidebar-settings-entry').trigger('click')
const cloudCard = wrapper.findAll('.settings-agent-card')
.find(card => card.text().includes('Codex @ Research server'))
expect(cloudCard.get('.settings-agent-logo.cloud').exists()).toBe(true)
expect(cloudCard.text()).toContain('Version 0.146.1')
await cloudCard.get('.agent-card-main').trigger('click')
await flushPromises()
expect(wrapper.get('.agent-detail-logo.cloud img').attributes('src'))
.toContain('agent-logos/codex.svg')
expect(wrapper.get('.agent-detail-metrics').text()).toContain('Research server · Version 0.146.1')
expect(wrapper.get('.agent-detail-skill-grid').text()).toContain('@Review')
expect(bridge.agentInstaller.skills).toHaveBeenCalledWith(cloudKind)
wrapper.unmount()
})
it('hides Custom Agent setup while the connector surface is not production-ready', async () => {
const customProfile = {
kind: 'custom-0123456789abcdef',
@@ -1209,7 +1270,7 @@ describe('Meldwork workbench', () => {
const { wrapper, bridge } = await mountApp()
await wrapper.get('.sidebar-settings-entry').trigger('click')
await wrapper.findAll('.settings-tabs button')[1].trigger('click')
await wrapper.findAll(".settings-tabs button")[2].trigger("click")
await wrapper.findAll('.provider-agent-list button')
.find(button => button.text().includes('Hermes'))
.trigger('click')
@@ -1255,7 +1316,7 @@ describe('Meldwork workbench', () => {
expect(bridge.localWorkspace.refreshAgents).toHaveBeenCalledTimes(1)
await wrapper.get('.sidebar-settings-entry').trigger('click')
await wrapper.findAll('.settings-tabs button')[1].trigger('click')
await wrapper.findAll(".settings-tabs button")[2].trigger("click")
await wrapper.findAll('.provider-agent-list button')
.find(button => button.text().includes('Hermes'))
.trigger('click')
@@ -1427,7 +1488,7 @@ describe('Meldwork workbench', () => {
expect(smartTeam.find('.smart-team-icon-state-off').exists()).toBe(true)
expect(smartTeam.find('.smart-team-icon-state-on').exists()).toBe(true)
expect(wrapper.find('.smart-team-tooltip').exists()).toBe(false)
expect(wrapper.get('.composer-box textarea').attributes('placeholder')).toBe('Message Review')
expect(wrapper.get('.composer-box textarea').attributes('placeholder')).toBe('Every Agent receives your message at once and replies independently, with no hand-offs.')
expect(wrapper.findAll('.target-chip').every(chip => chip.attributes('disabled') === undefined)).toBe(true)
await wrapper.get('.smart-team-control').trigger('mouseenter')
@@ -1480,7 +1541,7 @@ describe('Meldwork workbench', () => {
await smartTeam.trigger('click')
expect(smartTeam.get('.smart-team-status').text()).toBe('Off')
expect(wrapper.get('.composer-box textarea').attributes('placeholder')).toBe('Message Review')
expect(wrapper.get('.composer-box textarea').attributes('placeholder')).toBe('Every Agent receives your message at once and replies independently, with no hand-offs.')
expect(wrapper.findAll('.target-chip').every(chip => chip.attributes('disabled') === undefined)).toBe(true)
setLocale('zh')
@@ -150,7 +150,7 @@ describe('Unlimited-round review mode', () => {
await flushPromises()
expect(wrapper.get('.composer-box').classes()).not.toContain('unlimited-mode')
expect(wrapper.find('.round-unlimited-symbol').exists()).toBe(false)
expect(wrapper.get('.composer-box textarea').attributes('placeholder')).toBe('Message Review')
expect(wrapper.get('.composer-box textarea').attributes('placeholder')).toBe('Agents reply one at a time, in turn — each may build on the previous answer, up to the round limit.')
wrapper.unmount()
})
})
@@ -1,5 +1,6 @@
import { ref } from 'vue'
import { describe, expect, it } from 'vitest'
import { agentLogo, setCloudAgentProfiles } from '../../catalog.js'
import { useAgentCatalog } from '../../composables/useAgentCatalog.js'
import { setLocale, t } from '../../i18n.js'
@@ -17,6 +18,67 @@ function catalogFor(agent) {
}
describe('Agent compatibility state', () => {
it('keeps cloud Agents out of the local catalog and groups ready Agents by execution location', () => {
setLocale('en')
const cloudAgent = {
kind: 'cloud-0123456789abcdef01234567',
sourceKind: 'codex',
label: 'Cloud Codex',
custom: false,
connector: true,
cloud: true,
installed: true,
available: true,
credentialState: 'ready',
showInSidebar: true,
}
const catalog = useAgentCatalog({
activeGroup: ref(null),
directGroupsFor: () => [],
installCatalog: ref({ agents: [{ kind: 'codex', installed: true }, cloudAgent] }),
snapshot: ref({ agents: [
{ kind: 'codex', installed: true, available: true, credentialState: 'ready', showInSidebar: true },
cloudAgent,
] }),
t,
theme: ref('light'),
})
expect(catalog.agentCatalogGroups.value.find(group => group.id === 'official').agents.map(agent => agent.kind))
.not.toContain('cloud-0123456789abcdef01234567')
expect(catalog.readyAgentGroups.value.map(group => [group.id, group.agents.map(agent => agent.kind)]))
.toEqual([['local', ['codex']], ['cloud', ['cloud-0123456789abcdef01234567']]])
expect(catalog.sidebarAgentGroups.value.map(group => [group.id, group.agents.map(agent => agent.kind)]))
.toEqual([['local', ['codex']], ['cloud', ['cloud-0123456789abcdef01234567']]])
const mergedCloud = catalog.mergedCatalog.value.find(agent => agent.kind === 'cloud-0123456789abcdef01234567')
expect(mergedCloud.logo).toContain('agent-logos/codex.svg')
setCloudAgentProfiles([cloudAgent])
expect(agentLogo('cloud-0123456789abcdef01234567')).toContain('agent-logos/codex.svg')
})
it('keeps an installed cloud Agent that needs login in management but out of chat choices', () => {
setLocale('en')
const cloudAgent = {
kind: 'cloud-claude', sourceKind: 'claude', label: 'Claude Code @ Server',
cloud: true, connector: true, custom: false, installed: true,
available: false, credentialState: 'missing', showInSidebar: true,
}
const catalog = useAgentCatalog({
activeGroup: ref(null),
directGroupsFor: () => [],
installCatalog: ref({ agents: [cloudAgent] }),
snapshot: ref({ agents: [] }),
t,
theme: ref('light'),
})
expect(catalog.agentCatalogGroups.value.find(group => group.id === 'cloud').agents)
.toHaveLength(1)
expect(catalog.readyAgents.value).toHaveLength(0)
const mergedCloud = catalog.mergedCatalog.value.find(agent => agent.kind === 'cloud-claude')
expect(catalog.agentState(mergedCloud).label).toBe('Needs sign-in or Provider')
})
it('keeps ready OpenCodeReview available for sidebar and composer targets', () => {
setLocale('en')
const catalog = useAgentCatalog({
@@ -9,6 +9,30 @@ function deferred() {
}
describe('Agent Skill catalog', () => {
it('loads Skills for cloud connector Agents', async () => {
const installer = { skills: vi.fn(async kind => ({
supported: true,
total: 1,
skills: [{ targetKind: kind, namespace: 'hermes', slug: 'research', name: 'Research' }],
})) }
const skills = useAgentSkills({
installer: ref(installer),
mergedCatalog: ref([{
kind: 'cloud-hermes', cloud: true, connector: true, custom: false,
}]),
normalizeSkill: (skill, targetKind) => ({ ...skill, targetKind }),
readyAgents: ref([{ kind: 'cloud-hermes', cloud: true, custom: false }]),
t: key => key,
})
await skills.selectAgentDetail('cloud-hermes')
expect(installer.skills).toHaveBeenCalledWith('cloud-hermes')
expect(skills.agentDetailSkillItems.value).toEqual([{
targetKind: 'cloud-hermes', namespace: 'hermes', slug: 'research', name: 'Research',
}])
})
it('does not repopulate the cache after disposal', async () => {
const pending = deferred()
const skills = useAgentSkills({
@@ -0,0 +1,20 @@
import { ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import { useCloudAgentBridges } from '../../composables/useCloudAgentBridges.js'
describe('Cloud Agent bridges', () => {
it('refreshes the local Agent snapshot after connecting a server', async () => {
const refreshAgents = vi.fn(async () => {})
const bridgeApi = ref({
connect: vi.fn(async () => ({ bridgeId: 'cloud-bridge-1' })),
list: vi.fn(async () => []),
})
const bridges = useCloudAgentBridges({ bridgeApi, refreshAgents, showError: vi.fn() })
bridges.address.value = '10.1.132.21'
await bridges.connect()
expect(bridgeApi.value.connect).toHaveBeenCalledWith({ address: '10.1.132.21' })
expect(refreshAgents).toHaveBeenCalledTimes(1)
})
})
+13 -1
View File
@@ -26,7 +26,9 @@ export const AGENTS = Object.freeze([
const BY_KIND = new Map(AGENTS.map(agent => [agent.kind, agent]))
const CUSTOM_BY_KIND = new Map()
const CLOUD_BY_KIND = new Map()
const CUSTOM_AGENT_KIND = /^custom-[a-f0-9]{16}$/
const CLOUD_AGENT_KIND = /^cloud-[a-f0-9]{24}$/
const CUSTOM_AGENT_LOGO = publicAsset('agent-logos/custom-agent.svg')
export function setCustomAgentProfiles(profiles = []) {
@@ -49,8 +51,18 @@ export function setCustomAgentProfiles(profiles = []) {
}
}
export function setCloudAgentProfiles(profiles = []) {
CLOUD_BY_KIND.clear()
for (const profile of Array.isArray(profiles) ? profiles : []) {
const kind = String(profile?.kind || '')
const sourceKind = String(profile?.sourceKind || '')
if (!CLOUD_AGENT_KIND.test(kind) || !BY_KIND.has(sourceKind) || profile?.cloud !== true) continue
CLOUD_BY_KIND.set(kind, { ...BY_KIND.get(sourceKind), kind })
}
}
function agentProfile(kind) {
return BY_KIND.get(kind) || CUSTOM_BY_KIND.get(kind)
return BY_KIND.get(kind) || CUSTOM_BY_KIND.get(kind) || CLOUD_BY_KIND.get(kind)
|| { kind, label: kind || 'Agent', logo: CUSTOM_AGENT_LOGO, providerMode: 'native' }
}
@@ -30,7 +30,10 @@
:title="t('systemSettings.openAgentDetail', { agent: agent.label })"
@click="$emit('open-agent-detail', agent)"
>
<img :src="agent.logo" :alt="agent.label" />
<span class="settings-agent-logo" :class="{ cloud: agent.cloud }">
<img :src="agent.logo" :alt="agent.label" />
<CloudOutline v-if="agent.cloud" aria-hidden="true" />
</span>
<span class="agent-card-copy">
<span class="agent-name-row">
<strong>{{ agent.label }}</strong>
@@ -119,6 +122,7 @@
import {
ChatbubbleEllipsesOutline,
ChevronForwardOutline,
CloudOutline,
DownloadOutline,
KeyOutline,
RefreshOutline,
@@ -0,0 +1,104 @@
<template>
<section class="settings-panel cloud-agent-bridge-panel">
<div class="manager-toolbar">
<div>
<h2>{{ t('cloudAgents.title') }}</h2>
<p>{{ t('cloudAgents.subtitle') }}</p>
</div>
<button class="secondary-button" type="button" :disabled="loading || saving" @click="refresh">
<RefreshOutline :class="{ spinning: loading }" />
{{ t('cloudAgents.refresh') }}
</button>
</div>
<form class="cloud-agent-connect-form" @submit.prevent="connect">
<label>
<span>{{ t('cloudAgents.address') }}</span>
<input
v-model="address"
type="text"
inputmode="url"
:placeholder="t('cloudAgents.addressPlaceholder')"
:disabled="saving"
required
/>
</label>
<label>
<span>{{ t('cloudAgents.label') }}</span>
<input
v-model="label"
type="text"
autocomplete="off"
:placeholder="t('cloudAgents.labelPlaceholder')"
:disabled="saving"
/>
</label>
<button class="primary-button" type="submit" :disabled="saving || !address.trim()">
<CloudOutline />
{{ t('cloudAgents.connect') }}
</button>
</form>
<p v-if="error" class="cloud-agent-bridge-error" role="alert">{{ translateError(error) }}</p>
<div v-if="bridges.length" class="cloud-agent-bridge-list">
<article v-for="bridge in bridges" :key="bridge.bridgeId" class="cloud-agent-bridge-item">
<div class="cloud-agent-bridge-item-main">
<strong>{{ bridge.label }}</strong>
<span>{{ bridge.address }}</span>
<small :class="bridge.available ? 'bridge-online' : 'bridge-offline'">
{{ bridge.available ? t('cloudAgents.online') : t('cloudAgents.offline') }}
</small>
<small>{{ transportLabel(bridge.transport) }}</small>
<small v-if="bridge.agents?.length">
{{ t('cloudAgents.agentCount', { count: bridge.agents.length }) }}
</small>
<p v-if="bridge.agents?.length" class="cloud-agent-bridge-agent-list">
{{ agentSummary(bridge.agents) }}
</p>
<small v-if="!bridge.available && bridge.lastError">{{ translateError(bridge.lastError) }}</small>
</div>
<button
class="icon-button"
type="button"
:title="t('cloudAgents.delete')"
:aria-label="t('cloudAgents.delete')"
:disabled="saving"
@click="remove(bridge.bridgeId)"
>
<TrashOutline />
</button>
</article>
</div>
<p v-else-if="!loading" class="cloud-agent-bridge-empty">{{ t('cloudAgents.empty') }}</p>
</section>
</template>
<script setup>
import { CloudOutline, RefreshOutline, TrashOutline } from '@vicons/ionicons5'
import { t, translateError } from '../i18n.js'
const props = defineProps({
controller: { type: Object, required: true },
})
const {
address,
bridges,
connect,
error,
label,
loading,
refresh,
remove,
saving,
} = props.controller
function transportLabel(transport) {
return t(`cloudAgents.transport.${transport || 'bridge'}`)
}
function agentSummary(agents) {
return agents.map(agent => agent.label || agent.id).filter(Boolean).join(', ')
}
</script>
@@ -78,6 +78,7 @@
:class="{ active: discussionMode === 'manual' }"
:aria-pressed="discussionMode === 'manual'"
:disabled="Boolean(activeRun) || sending"
:title="t('composer.concurrentResponsesHint')"
@click="discussionMode = 'manual'"
>
{{ manualModeLabel }}
@@ -88,10 +89,25 @@
:class="{ active: discussionMode === 'auto' }"
:aria-pressed="discussionMode === 'auto'"
:disabled="Boolean(activeRun) || sending"
:title="t('composer.sequentialHint')"
@click="discussionMode = 'auto'"
>
{{ t('composer.auto') }}
</button>
<button
type="button"
data-mode="auto-beta"
class="mode-beta"
:class="{ active: discussionMode === 'auto-beta' }"
:aria-pressed="discussionMode === 'auto-beta'"
:disabled="Boolean(activeRun) || sending"
:title="t('composer.autoBetaHint')"
:aria-label="`${t('composer.autoBeta')} — ${t('composer.autoBetaHint')}`"
@click="discussionMode = 'auto-beta'"
>
{{ t('composer.autoBeta') }}
<span class="mode-beta-badge" :aria-label="t('composer.autoBetaBadge')">{{ t('composer.autoBetaBadge') }}</span>
</button>
</div>
<div class="target-row" :aria-label="t('composer.targets')">
@@ -104,11 +120,11 @@
:style="{ '--stack-index': index }"
type="button"
:title="agentLabel(kind)"
:aria-label="discussionMode === 'auto'
:aria-label="(discussionMode === 'auto' || discussionMode === 'auto-beta')
? t('composer.autoTarget', { agent: agentLabel(kind) })
: t(isComposerTargetSelected(kind) ? 'composer.removeTarget' : 'composer.addTarget', { agent: agentLabel(kind) })"
:aria-pressed="isComposerTargetSelected(kind)"
:disabled="Boolean(activeRun) || sending || discussionMode === 'auto' || automaticTeamFormation"
:disabled="Boolean(activeRun) || sending || discussionMode === 'auto' || discussionMode === 'auto-beta' || automaticTeamFormation"
@click="toggleTarget(kind)"
>
<img :src="agentLogo(kind, theme)" alt="" />
@@ -118,7 +134,7 @@
</div>
<div
v-if="discussionMode === 'auto'"
v-if="discussionMode === 'auto' || discussionMode === 'auto-beta'"
ref="roundSettingsControl"
class="round-settings-control"
@click.stop
@@ -284,13 +300,7 @@
ref="composerInput"
v-model="draft"
rows="1"
:placeholder="automaticTeamFormation
? t('composer.smartTeamPlaceholder')
: t(discussionMode === 'auto' && unlimitedRounds
? 'composer.unlimitedPlaceholder'
: 'composer.placeholder', {
name: groupName(activeGroup),
})"
:placeholder="composerPlaceholder"
:disabled="Boolean(activeRun) || sending"
role="combobox"
aria-autocomplete="list"
@@ -573,6 +583,23 @@ const unlimitedModeActive = computed(() => (
? activeUnlimitedAutoRun.value
: discussionMode.value === 'auto' && unlimitedRounds.value === true
))
const modeExplanation = computed(() => {
if (discussionMode.value === 'auto') return t('composer.sequentialHint')
if (discussionMode.value === 'auto-beta') return t('composer.autoBetaHint')
return t('composer.concurrentResponsesHint')
})
const composerPlaceholder = computed(() => {
const group = activeGroup.value
if (!group) return t('composer.placeholder', { name: '' })
if (group.conversationType === 'direct') {
return t('composer.placeholder', { name: groupName(group) })
}
if (automaticTeamFormation.value) return t('composer.smartTeamPlaceholder')
if (discussionMode.value === 'auto' && unlimitedRounds.value) {
return t('composer.unlimitedPlaceholder', { name: groupName(group) })
}
return modeExplanation.value
})
const composerMentionSignature = computed(() => JSON.stringify([
selectedAgentKinds.value,
selectedKnowledgeBases.value.map(source => source.kind),
+18 -1
View File
@@ -19,6 +19,16 @@
<TerminalOutline />
{{ t('systemSettings.agents') }}
</button>
<button
type="button"
role="tab"
:aria-selected="systemSettingsSection === 'cloud-agents'"
:class="{ active: systemSettingsSection === 'cloud-agents' }"
@click="selectSystemSettingsSection('cloud-agents')"
>
<CloudOutline />
{{ t('systemSettings.cloudServers') }}
</button>
<button
type="button"
role="tab"
@@ -67,6 +77,11 @@
@request-install="requestInstall"
/>
<CloudAgentBridgePanel
v-else-if="systemSettingsSection === 'cloud-agents'"
:controller="cloudAgentBridges"
/>
<KnowledgeBaseSettingsPanel
v-else-if="systemSettingsSection === 'knowledge-bases'"
:icon="knowledgeBaseIcon"
@@ -96,8 +111,9 @@
</template>
<script setup>
import { KeyOutline, LibraryOutline, TerminalOutline } from '@vicons/ionicons5'
import { CloudOutline, KeyOutline, LibraryOutline, TerminalOutline } from '@vicons/ionicons5'
import AgentSettingsPanel from './AgentSettingsPanel.vue'
import CloudAgentBridgePanel from './CloudAgentBridgePanel.vue'
import KnowledgeBaseSettingsPanel from './KnowledgeBaseSettingsPanel.vue'
import ProviderSettingsPanel from './ProviderSettingsPanel.vue'
@@ -112,6 +128,7 @@ const {
agentSkillLabel,
agentState,
cancelInstall,
cloudAgentBridges,
focusedAgentKind,
formError,
installConfirmKind,
@@ -13,19 +13,30 @@
<span>{{ t('group.agents') }}</span>
<small>{{ t('group.selectedCount', { count: groupForm.agentKinds.length }) }}</small>
</legend>
<div class="agent-choice-grid">
<label
v-for="agent in readyAgents"
:key="agent.kind"
class="agent-choice"
:class="{ selected: groupForm.agentKinds.includes(agent.kind) }"
:title="agent.label"
>
<input v-model="groupForm.agentKinds" type="checkbox" :value="agent.kind" />
<img :src="agent.logo" alt="" />
<span>{{ agent.label }}</span>
<CheckmarkCircleOutline />
</label>
<div class="agent-choice-location-groups">
<section v-for="agentGroup in readyAgentGroups" :key="agentGroup.id" class="agent-choice-location-group">
<header>
<strong>{{ t(agentGroup.titleKey) }}</strong>
<small>{{ t(agentGroup.hintKey) }}</small>
</header>
<div class="agent-choice-grid">
<label
v-for="agent in agentGroup.agents"
:key="agent.kind"
class="agent-choice"
:class="{ selected: groupForm.agentKinds.includes(agent.kind), cloud: agent.cloud }"
:title="agent.label"
>
<input v-model="groupForm.agentKinds" type="checkbox" :value="agent.kind" />
<span class="agent-choice-logo" :class="{ cloud: agent.cloud }">
<img :src="agent.logo" alt="" />
<CloudOutline v-if="agent.cloud" aria-hidden="true" />
</span>
<span>{{ agent.label }}</span>
<CheckmarkCircleOutline class="agent-choice-check" />
</label>
</div>
</section>
</div>
</fieldset>
<label>
@@ -38,11 +49,12 @@
</button>
</div>
</label>
<label class="switch-row">
<input v-model="groupForm.allowWrite" type="checkbox" :disabled="saving" />
<label class="switch-row" :class="{ disabled: groupSelectionIsCloudOnly }">
<input v-model="groupForm.allowWrite" type="checkbox" :disabled="saving || groupSelectionIsCloudOnly" />
<span class="switch-control" />
<span>{{ t('group.allowWrite') }}</span>
<span>{{ groupSelectionIsCloudOnly ? t('group.cloudReadOnly') : t('group.allowWrite') }}</span>
</label>
<p v-if="groupSelectionIncludesCloud" class="group-cloud-notice">{{ t('group.cloudSelectionNotice') }}</p>
<p v-if="formError" class="form-error" role="alert">{{ formError }}</p>
<footer class="modal-footer">
<button class="secondary-button" type="button" :disabled="saving" @click="closeModal">{{ t('common.cancel') }}</button>
@@ -79,19 +91,29 @@
<span>{{ t('group.agents') }}</span>
<small>{{ t('group.selectedCount', { count: settingsForm.agentKinds.length }) }}</small>
</legend>
<div class="agent-choice-grid settings-agent-choice-grid">
<label
v-for="agent in readyAgents"
:key="agent.kind"
class="agent-choice settings-agent-choice"
:class="{ selected: settingsForm.agentKinds.includes(agent.kind) }"
:title="agent.label"
>
<input v-model="settingsForm.agentKinds" type="checkbox" :value="agent.kind" />
<img :src="agent.logo" alt="" />
<span>{{ agent.label }}</span>
<CheckmarkCircleOutline />
</label>
<div class="agent-choice-location-groups settings-agent-choice-location-groups">
<section v-for="agentGroup in readyAgentGroups" :key="agentGroup.id" class="agent-choice-location-group">
<header>
<strong>{{ t(agentGroup.titleKey) }}</strong>
</header>
<div class="agent-choice-grid settings-agent-choice-grid">
<label
v-for="agent in agentGroup.agents"
:key="agent.kind"
class="agent-choice settings-agent-choice"
:class="{ selected: settingsForm.agentKinds.includes(agent.kind), cloud: agent.cloud }"
:title="agent.label"
>
<input v-model="settingsForm.agentKinds" type="checkbox" :value="agent.kind" />
<span class="agent-choice-logo" :class="{ cloud: agent.cloud }">
<img :src="agent.logo" alt="" />
<CloudOutline v-if="agent.cloud" aria-hidden="true" />
</span>
<span>{{ agent.label }}</span>
<CheckmarkCircleOutline class="agent-choice-check" />
</label>
</div>
</section>
</div>
</fieldset>
<label>
@@ -104,11 +126,12 @@
</button>
</div>
</label>
<label class="switch-row">
<input v-model="settingsForm.allowWrite" type="checkbox" :disabled="saving" />
<label class="switch-row" :class="{ disabled: settingsSelectionIsCloudOnly }">
<input v-model="settingsForm.allowWrite" type="checkbox" :disabled="saving || settingsSelectionIsCloudOnly" />
<span class="switch-control" />
<span>{{ t('group.allowWrite') }}</span>
<span>{{ settingsSelectionIsCloudOnly ? t('group.cloudReadOnly') : t('group.allowWrite') }}</span>
</label>
<p v-if="settingsSelectionIncludesCloud" class="group-cloud-notice">{{ t('group.cloudSelectionNotice') }}</p>
<p v-if="formError" class="form-error" role="alert">{{ formError }}</p>
<footer class="modal-footer settings-modal-footer">
<div class="settings-delete-control">
@@ -219,7 +242,10 @@
<section v-else-if="modal === 'agent-detail' && selectedAgentDetail" class="modal-body agent-detail-body">
<div class="agent-detail-hero">
<img :src="selectedAgentDetail.logo" :alt="selectedAgentDetail.label" />
<span class="agent-detail-logo" :class="{ cloud: selectedAgentDetail.cloud }">
<img :src="selectedAgentDetail.logo" :alt="selectedAgentDetail.label" />
<CloudOutline v-if="selectedAgentDetail.cloud" aria-hidden="true" />
</span>
<div>
<span class="agent-state" :class="agentState(selectedAgentDetail).tone">
<component :is="agentState(selectedAgentDetail).icon" />
@@ -235,8 +261,15 @@
<small>{{ agentDetailSkillSummary }}</small>
</span>
<span>
<strong>{{ t('agentDetail.provider') }}</strong>
<small>{{ providerSummaryLabel(selectedAgentDetail) }}</small>
<strong>{{ selectedAgentDetail.cloud ? t('agentDetail.location') : t('agentDetail.provider') }}</strong>
<small>
{{ selectedAgentDetail.cloud
? t('agentDetail.cloudLocation', {
server: selectedAgentDetail.serverLabel,
version: selectedAgentDetail.version,
})
: providerSummaryLabel(selectedAgentDetail) }}
</small>
</span>
<span>
<strong>{{ t('agentDetail.images') }}</strong>
@@ -264,7 +297,7 @@
</label>
<section class="agent-detail-section">
<h3>{{ t('agentDetail.localSkills') }}</h3>
<h3>{{ t(selectedAgentDetail.cloud ? 'agentDetail.remoteSkills' : 'agentDetail.localSkills') }}</h3>
<p v-if="agentDetailSkillsLoading" class="agent-detail-muted">{{ t('agent.skillsLoading') }}</p>
<p v-else-if="!agentDetailSkillItems.length" class="agent-detail-muted">{{ t('agent.skillsUnavailable') }}</p>
<div v-else class="agent-detail-skill-grid">
@@ -322,10 +355,11 @@
</template>
<script setup>
import { ref } from 'vue'
import { computed, ref, watch } from 'vue'
import {
ChatbubbleEllipsesOutline,
CheckmarkCircleOutline,
CloudOutline,
DownloadOutline,
FolderOpenOutline,
KeyOutline,
@@ -334,6 +368,7 @@ import {
} from '@vicons/ionicons5'
import { skillKey } from '../composables/useComposerContext.js'
const props = defineProps({
controller: { type: Object, required: true },
})
@@ -367,7 +402,7 @@ const {
openProvider,
pickGroupDirectory,
providerSummaryLabel,
readyAgents,
readyAgentGroups,
requestDeleteConfirmation,
requestInstall,
saveGroupSettings,
@@ -379,6 +414,26 @@ const {
t,
} = props.controller
const cloudAgentKinds = computed(() => new Set(
readyAgentGroups.value.flatMap(group => group.agents).filter(agent => agent.cloud).map(agent => agent.kind),
))
function selectionIncludesCloud(agentKinds) {
return agentKinds.some(kind => cloudAgentKinds.value.has(kind))
}
function selectionIsCloudOnly(agentKinds) {
return agentKinds.length > 0 && agentKinds.every(kind => cloudAgentKinds.value.has(kind))
}
const groupSelectionIncludesCloud = computed(() => selectionIncludesCloud(groupForm.agentKinds))
const groupSelectionIsCloudOnly = computed(() => selectionIsCloudOnly(groupForm.agentKinds))
const settingsSelectionIncludesCloud = computed(() => selectionIncludesCloud(settingsForm.agentKinds))
const settingsSelectionIsCloudOnly = computed(() => selectionIsCloudOnly(settingsForm.agentKinds))
watch(groupSelectionIsCloudOnly, cloudOnly => { if (cloudOnly) groupForm.allowWrite = false })
watch(settingsSelectionIsCloudOnly, cloudOnly => { if (cloudOnly) settingsForm.allowWrite = false })
const settingsNameInput = ref(null)
function focusSettingsName() {
+19 -7
View File
@@ -52,7 +52,9 @@
<span>{{ t('nav.sidebarAgents') }}</span>
</button>
<div v-if="!sidebarAgentsCollapsed" id="sidebar-agent-list">
<article v-for="agent in sidebarAgents" :key="agent.kind" class="sidebar-agent">
<div v-for="agentGroup in sidebarAgentGroups" :key="agentGroup.id" class="sidebar-agent-location-group">
<p class="sidebar-agent-location-heading">{{ t(agentGroup.titleKey) }}</p>
<article v-for="agent in agentGroup.agents" :key="agent.kind" class="sidebar-agent">
<header class="sidebar-agent-header">
<button
class="sidebar-agent-main"
@@ -63,7 +65,10 @@
:aria-controls="directGroupsFor(agent.kind).length ? sidebarAgentSessionListId(agent.kind) : undefined"
@click="handleSidebarAgentMain(agent)"
>
<img :src="agent.logo" :alt="agent.label" />
<span class="agent-logo-cloud" :class="{ cloud: agent.cloud }">
<img :src="agent.logo" :alt="agent.label" />
<CloudOutline v-if="agent.cloud" aria-hidden="true" />
</span>
<span>
<strong>{{ agent.label }}</strong>
<small>
@@ -161,9 +166,10 @@
<span>{{ t(isDirectSessionListExpanded(agent.kind) ? 'nav.lessItems' : 'nav.moreItems') }}</span>
</button>
</div>
</article>
</article>
</div>
</div>
<p v-if="!sidebarAgents.length && !sidebarAgentsCollapsed" class="nav-empty">{{ t('nav.noSidebarAgents') }}</p>
<p v-if="!sidebarAgentGroups.length && !sidebarAgentsCollapsed" class="nav-empty">{{ t('nav.noSidebarAgents') }}</p>
</section>
<section class="nav-section group-nav-section">
@@ -192,7 +198,9 @@
:aria-current="isSelectedConversation(group) ? 'page' : undefined"
@click="selectGroup(group.id)"
>
<span class="group-avatar"><ChatbubblesOutline /></span>
<span class="group-avatar stack" :aria-label="groupAgentSummary(group)">
<img v-for="kind in group.agentKinds.slice(0, 3)" :key="kind" :src="agentLogo(kind, theme)" alt="" />
</span>
<span>{{ groupName(group) }}</span>
<span
v-if="isGroupRunning(group.id) && !isSelectedConversation(group)"
@@ -344,7 +352,9 @@
:aria-current="activeView === 'conversation' && selectedGroupId === group.id ? 'page' : undefined"
@click="selectGroup(group.id)"
>
<span class="group-avatar"><ChatbubblesOutline /></span>
<span class="group-avatar stack" :aria-label="groupAgentSummary(group)">
<img v-for="kind in group.agentKinds.slice(0, 3)" :key="kind" :src="agentLogo(kind, theme)" alt="" />
</span>
<span class="collapsed-group-option-main">
<strong>{{ groupName(group) }}</strong>
<small>{{ groupAgentSummary(group) }}</small>
@@ -397,6 +407,7 @@ import {
CheckmarkCircleOutline,
ChevronBackOutline,
ChevronForwardOutline,
CloudOutline,
LanguageOutline,
MoonOutline,
PencilOutline,
@@ -405,6 +416,7 @@ import {
SunnyOutline,
TrashOutline,
} from '@vicons/ionicons5'
import { agentLogo } from '../catalog.js'
const props = defineProps({
controller: { type: Object, required: true },
@@ -446,7 +458,7 @@ const {
selectGroup,
selectedGroupId,
sidebarAgentSessionListId,
sidebarAgents,
sidebarAgentGroups,
sidebarAgentsCollapsed,
sidebarGroupsCollapsed,
sidebarCollapsed,
+68 -5
View File
@@ -5,6 +5,7 @@ import {
WarningOutline,
} from '@vicons/ionicons5'
import { AGENTS, agentLogo } from '../catalog.js'
import { MAX_ATTACHMENTS } from './useComposerAttachments.js'
export function useAgentCatalog({
activeGroup,
@@ -14,7 +15,23 @@ export function useAgentCatalog({
t,
theme,
}) {
const mergedCatalog = computed(() => AGENTS.map((profile) => {
const mergedCatalog = computed(() => {
const extraProfiles = (installCatalog.value.agents || [])
.filter(agent => agent?.cloud === true && !AGENTS.some(profile => profile.kind === agent.kind))
.map(agent => ({
kind: agent.kind,
sourceKind: agent.sourceKind,
label: agent.label,
logo: agent.logo,
providerMode: agent.providerMode || 'connector',
imageLimit: Number(agent.imageAttachmentLimit || 0),
custom: false,
connector: true,
description: agent.description || '',
cloud: agent.cloud === true,
}))
const profiles = [...AGENTS, ...extraProfiles]
return profiles.map((profile) => {
const installedProfile = installCatalog.value.agents?.find(agent => agent.kind === profile.kind) || {}
const detected = snapshot.value.agents.find(agent => agent.kind === profile.kind) || {}
return {
@@ -22,7 +39,10 @@ export function useAgentCatalog({
...installedProfile,
...detected,
label: profile.label,
logo: agentLogo(profile.kind, theme.value),
logo: agentLogo(
detected.sourceKind || installedProfile.sourceKind || profile.sourceKind || profile.kind,
theme.value,
),
providerMode: profile.providerMode,
imageLimit: profile.custom
? MAX_ATTACHMENTS
@@ -30,17 +50,44 @@ export function useAgentCatalog({
installed: Boolean(installedProfile.installed || detected.installed),
ready: detected.available === true,
}
}))
})
})
const agentCatalogGroups = computed(() => [
{
id: 'official',
titleKey: 'systemSettings.officialAgents',
subtitleKey: 'systemSettings.officialAgentsHint',
agents: mergedCatalog.value.filter(agent => !agent.custom),
agents: mergedCatalog.value.filter(agent => !agent.custom && !agent.cloud),
},
])
{
id: 'cloud',
titleKey: 'systemSettings.cloudAgents',
subtitleKey: 'systemSettings.cloudAgentsHint',
agents: mergedCatalog.value.filter(agent => agent.cloud === true),
},
{
id: 'custom',
titleKey: 'systemSettings.customAgents',
subtitleKey: 'systemSettings.customAgentsHint',
agents: mergedCatalog.value.filter(agent => agent.custom && agent.cloud !== true),
},
].filter(category => category.agents.length > 0))
const readyAgents = computed(() => mergedCatalog.value.filter(agent => agent.ready))
const readyAgentGroups = computed(() => [
{
id: 'local',
titleKey: 'agentLocation.local',
hintKey: 'group.agentLocationLocalHint',
agents: readyAgents.value.filter(agent => !agent.cloud),
},
{
id: 'cloud',
titleKey: 'agentLocation.cloud',
hintKey: 'group.agentLocationCloudHint',
agents: readyAgents.value.filter(agent => agent.cloud),
},
].filter(group => group.agents.length))
const readyAgentSignature = computed(() => readyAgents.value.map(agent => agent.kind).join('\u0000'))
const readyAgentKinds = computed(() => new Set(readyAgents.value.map(agent => agent.kind)))
const readyCount = computed(() => readyAgents.value.length)
@@ -49,6 +96,18 @@ export function useAgentCatalog({
if (agent.ready) return agent.showInSidebar !== false
return directGroupsFor(agent.kind).length > 0
}))
const sidebarAgentGroups = computed(() => [
{
id: 'local',
titleKey: 'agentLocation.local',
agents: sidebarAgents.value.filter(agent => !agent.cloud),
},
{
id: 'cloud',
titleKey: 'agentLocation.cloud',
agents: sidebarAgents.value.filter(agent => agent.cloud),
},
].filter(group => group.agents.length))
const activeDirectAgent = computed(() => {
if (activeGroup.value?.conversationType !== 'direct') return null
return mergedCatalog.value.find(agent => agent.kind === activeGroup.value.directAgentKind) || null
@@ -60,12 +119,14 @@ export function useAgentCatalog({
function agentDescription(kind) {
const profile = mergedCatalog.value.find(agent => agent.kind === kind)
if (profile?.cloud && profile.description) return profile.description
if (profile?.custom && profile.description) return profile.description
if (profile?.custom) return t('customAgent.defaultDescription')
return t(`agent.description.${kind}`)
}
function agentSoul(agent) {
if (agent?.cloud) return agent.description || t('cloudAgents.agentDescription')
if (agent?.custom) return agent.description || t('customAgent.detailBody')
return t(`agent.soul.${agent?.kind}`)
}
@@ -103,9 +164,11 @@ export function useAgentCatalog({
mergedCatalog,
providerModeShortLabel,
readyAgentKinds,
readyAgentGroups,
readyAgentSignature,
readyAgents,
readyCount,
sidebarAgents,
sidebarAgentGroups,
}
}
+2 -1
View File
@@ -1,4 +1,4 @@
import { setCustomAgentProfiles } from '../catalog.js'
import { setCloudAgentProfiles, setCustomAgentProfiles } from '../catalog.js'
import { normalizeSnapshot } from '../desktop.js'
export function useAgentRefresh({
@@ -25,6 +25,7 @@ export function useAgentRefresh({
agents: Array.isArray(catalog.agents) ? catalog.agents : [],
}
setCustomAgentProfiles(installCatalog.value.agents.filter(agent => agent?.custom === true))
setCloudAgentProfiles(installCatalog.value.agents.filter(agent => agent?.cloud === true))
}
async function performAgentRefresh() {
+5 -2
View File
@@ -24,7 +24,9 @@ export function useAgentSkills({
if (selectedAgentDetail.value?.custom) return t('customAgent.skillsUnsupported')
if (agentDetailSkillsLoading.value) return t('agent.skillsLoading')
if (!agentDetailSkillItems.value.length) return t('agent.skillsUnavailable')
return t('agent.localSkills', { count: agentDetailSkillItems.value.length })
return t(selectedAgentDetail.value?.cloud ? 'agent.cloudSkills' : 'agent.localSkills', {
count: agentDetailSkillItems.value.length,
})
})
function agentSkillLabel(kind) {
@@ -34,7 +36,8 @@ export function useAgentSkills({
const state = agentSkillStats.value[kind]
if (!state || state.loading) return t('agent.skillsLoading')
if (!Number.isFinite(state.total)) return t('agent.skillsUnavailable')
return t('agent.localSkills', { count: state.total })
const cloud = mergedCatalog.value.find(agent => agent.kind === kind)?.cloud
return t(cloud ? 'agent.cloudSkills' : 'agent.localSkills', { count: state.total })
}
function normalizeAgentSkillCatalog(result, kind) {
@@ -0,0 +1,90 @@
import { computed, ref, watch } from 'vue'
export function useCloudAgentBridges({ bridgeApi, refreshAgents, showError }) {
const bridges = ref([])
const address = ref('')
const label = ref('')
const loading = ref(false)
const saving = ref(false)
const error = ref('')
async function load() {
if (!bridgeApi.value?.list) return
loading.value = true
try {
bridges.value = await bridgeApi.value.list()
error.value = ''
} catch (reason) {
error.value = String(reason?.code || reason?.message || 'CLOUD_AGENT_BRIDGE_LIST_FAILED')
} finally {
loading.value = false
}
}
async function connect() {
if (!bridgeApi.value?.connect || !address.value.trim() || saving.value) return
saving.value = true
error.value = ''
try {
await bridgeApi.value.connect({
address: address.value.trim(),
...(label.value.trim() ? { label: label.value.trim() } : {}),
})
address.value = ''
label.value = ''
await load()
await refreshAgents?.()
} catch (reason) {
error.value = String(reason?.code || reason?.message || 'CLOUD_AGENT_BRIDGE_CONNECT_FAILED')
showError?.(reason)
} finally {
saving.value = false
}
}
async function refresh() {
if (!bridgeApi.value?.refresh || saving.value) return
saving.value = true
try {
bridges.value = await bridgeApi.value.refresh()
error.value = ''
await refreshAgents?.()
} catch (reason) {
error.value = String(reason?.code || reason?.message || 'CLOUD_AGENT_BRIDGE_REFRESH_FAILED')
showError?.(reason)
} finally {
saving.value = false
}
}
async function remove(bridgeId) {
if (!bridgeApi.value?.delete || saving.value) return
saving.value = true
try {
await bridgeApi.value.delete(bridgeId)
await load()
await refreshAgents?.()
} catch (reason) {
error.value = String(reason?.code || reason?.message || 'CLOUD_AGENT_BRIDGE_DELETE_FAILED')
showError?.(reason)
} finally {
saving.value = false
}
}
watch(bridgeApi, value => {
if (value) void load()
}, { immediate: true })
return {
address,
bridges,
connect,
error: computed(() => error.value),
label,
loading,
refresh,
remove,
saving,
}
}
@@ -71,6 +71,7 @@ export function useComposerContext({
})
const composerMode = computed(() => {
if (activeGroup.value?.conversationType === 'direct') return 'manual'
if (discussionMode.value === 'auto-beta') return 'auto-beta'
return addressedAgentKinds.value.length === 1 ? 'manual' : discussionMode.value
})
const manualModeLabel = computed(() => (
@@ -94,7 +94,7 @@ export function useConversationActions({
: t('conversation.directDefaultName', { agent: agent.label, count: sessionCount }),
agentKinds: [kind],
workdir: await ensureDefaultDirectory(),
allowWrite: true,
allowWrite: agent.cloud !== true,
})
snapshot.value = normalizeSnapshot(await workspace.value.get())
void preloadAgentSkills(group.agentKinds)
@@ -66,6 +66,10 @@ export function useConversationExecution({
notify(t('composer.messageRequired'))
return
}
if (mode === 'auto-beta') {
notify(`${t('composer.autoBetaLockTitle')} — ${t('composer.autoBetaLockBody')}`)
return
}
const targets = [...composerTargetKinds.value]
if (!targets.length) {
notify(t('composer.selectTarget'))
@@ -1,5 +1,6 @@
function normalizedSection(section) {
if (section === 'providers') return 'providers'
if (section === 'cloud-agents') return 'cloud-agents'
if (section === 'knowledge-bases') return 'knowledge-bases'
return 'agents'
}
+21
View File
@@ -152,6 +152,27 @@ export const DESKTOP_ERROR_MESSAGE_KEYS = Object.freeze({
LOCAL_SKILL_LIMIT: 'error.skillLimit',
LOCAL_SKILL_SELECTION_INVALID: 'error.skillInvalid',
LOCAL_KNOWLEDGE_BASE_SELECTION_INVALID: 'error.knowledgeBaseInvalid',
CLOUD_AGENT_BRIDGE_ADDRESS_INVALID: 'error.cloudAgentBridgeAddress',
CLOUD_AGENT_BRIDGE_MANIFEST_INVALID: 'error.cloudAgentBridgeManifest',
CLOUD_AGENT_BRIDGE_TIMEOUT: 'error.cloudAgentBridgeNetwork',
CLOUD_AGENT_BRIDGE_PROBE_FAILED: 'error.cloudAgentBridgeNetwork',
CLOUD_AGENT_BRIDGE_RESPONSE_INVALID: 'error.cloudAgentBridgeProtocol',
CLOUD_AGENT_BRIDGE_RESPONSE_TOO_LARGE: 'error.cloudAgentBridgeProtocol',
CLOUD_AGENT_BRIDGE_HTTP_401: 'error.cloudAgentBridgeAuth',
CLOUD_AGENT_BRIDGE_HTTP_403: 'error.cloudAgentBridgeAuth',
CLOUD_AGENT_BRIDGE_HTTP_404: 'error.cloudAgentBridgeProtocol',
CLOUD_AGENT_BRIDGE_HTTP_500: 'error.cloudAgentBridgeNetwork',
CLOUD_AGENT_SSH_ADDRESS_INVALID: 'error.cloudAgentSshAddress',
CLOUD_AGENT_SSH_UNAVAILABLE: 'error.cloudAgentSshUnavailable',
CLOUD_AGENT_SSH_NO_AGENT: 'error.cloudAgentSshNoAgent',
CLOUD_AGENT_SSH_RESPONSE_INVALID: 'error.cloudAgentSshProtocol',
CLOUD_AGENT_SSH_RESPONSE_TOO_LARGE: 'error.cloudAgentSshProtocol',
CLOUD_AGENT_SSH_PERMISSION_UNSUPPORTED: 'error.cloudAgentSshPermission',
CLOUD_AGENT_SSH_TUNNEL_INVALID: 'error.cloudAgentSshUnavailable',
CLOUD_AGENT_SSH_TUNNEL_TIMEOUT: 'error.cloudAgentSshUnavailable',
CLOUD_AGENT_SSH_TUNNEL_UNAVAILABLE: 'error.cloudAgentSshUnavailable',
CLOUD_AGENT_SSH_TUNNEL_FAILED: 'error.cloudAgentSshUnavailable',
CLOUD_AGENT_SSH_TUNNEL_PROTOCOL: 'error.cloudAgentSshProtocol',
})
const LEGACY_ERROR_PATTERNS = [
+54 -4
View File
@@ -105,6 +105,7 @@ export default {
'agent.incompatibleProtocol': 'Required protocol unavailable',
'agent.detectedVersion': 'Version {version}',
'agent.localSkills': '{count} local skills',
'agent.cloudSkills': '{count} remote skills',
'agent.skillsLoading': 'Reading local skills',
'agent.skillsUnavailable': 'Skill count unavailable',
'agent.images': 'Up to {count} images',
@@ -115,6 +116,7 @@ export default {
'agent.providerShort.anthropic': 'Anthropic compatible',
'agent.providerShort.native': 'Native Provider',
'agent.providerShort.custom': 'CLI managed',
'agent.provider.connector': 'Remote CLI connection',
'agent.provider.compatible': 'Supports an individual OpenAI-compatible Provider',
'agent.provider.experimental': 'Individual external Provider support is experimental',
'agent.provider.responses': 'Uses a Responses API-compatible Provider',
@@ -401,9 +403,16 @@ export default {
'composer.send': 'Send',
'composer.stop': 'Stop',
'composer.responseMode': 'Response mode',
'composer.manual': 'Single response',
'composer.concurrentResponses': 'Concurrent responses',
'composer.auto': 'Auto discussion',
'composer.manual': 'Single reply',
'composer.concurrentResponses': 'Concurrent replies',
'composer.concurrentResponsesHint': 'Every Agent receives your message at once and replies independently, with no hand-offs.',
'composer.auto': 'Sequential discussion',
'composer.sequentialHint': 'Agents reply one at a time, in turn — each may build on the previous answer, up to the round limit.',
'composer.autoBeta': 'Auto discussion',
'composer.autoBetaHint': 'Beta: Agents @ each other into the discussion and the model orchestrates the rounds autonomously.',
'composer.autoBetaBadge': 'Beta',
'composer.autoBetaLockTitle': 'Auto discussion is a Beta feature',
'composer.autoBetaLockBody': 'True auto discussion — Agents autonomously @ each other to collaborate — is coming soon.',
'composer.smartTeam': 'Smart team',
'composer.smartTeamOn': 'On',
'composer.smartTeamOff': 'Off',
@@ -471,6 +480,10 @@ export default {
'group.workspace': 'Working directory',
'group.pickFolder': 'Choose folder',
'group.allowWrite': 'Allow Agents to create and edit files in this working directory',
'group.agentLocationLocalHint': 'Runs on this device and can access the local working directory under this group permission.',
'group.agentLocationCloudHint': 'Runs through a connected server and currently supports read-only tasks.',
'group.cloudReadOnly': 'Cloud Agents support read-only tasks only',
'group.cloudSelectionNotice': 'Cloud Agents do not receive local working-directory write access or attachments.',
'group.create': 'Create group',
'group.defaultName': 'Agent group',
'group.createErrorAgents': 'Select at least one ready Agent.',
@@ -485,8 +498,9 @@ export default {
'settings.deleteConfirm': 'Confirm delete',
'settings.deleteHint': 'Deletes this Meldwork conversation and its saved messages. Native CLI sessions are not deleted.',
'systemSettings.title': 'Settings',
'systemSettings.subtitle': 'Manage local Agents, their individual Provider connections, and knowledge bases.',
'systemSettings.subtitle': 'Manage local and cloud Agents, Provider connections, and knowledge bases.',
'systemSettings.agents': 'Agents',
'systemSettings.cloudServers': 'Cloud servers',
'systemSettings.providers': 'Providers',
'systemSettings.knowledgeBases': 'Knowledge bases',
'systemSettings.providerForAgent': 'Provider',
@@ -494,6 +508,8 @@ export default {
'systemSettings.openAgentDetailDefault': 'Agent details',
'systemSettings.officialAgents': 'Official Agents',
'systemSettings.officialAgentsHint': 'Built-in CLI connections maintained by Meldwork.',
'systemSettings.cloudAgents': 'Cloud Agents',
'systemSettings.cloudAgentsHint': 'Agents discovered from connected cloud servers.',
'systemSettings.customAgents': 'Custom Agents',
'systemSettings.customAgentsHint': 'Local CLI connections selected and managed on this device.',
'systemSettings.agentCount': '{count} Agents',
@@ -521,6 +537,25 @@ export default {
'customAgent.delete': 'Remove Agent',
'customAgent.deleteConfirm': 'Confirm removal',
'customAgent.empty': 'No custom Agents have been added.',
'cloudAgents.title': 'Cloud servers',
'cloudAgents.subtitle': 'Enter a server IP. Meldwork uses configured SSH access first, then tries its HTTPS Agent Bridge.',
'cloudAgents.address': 'Server address',
'cloudAgents.addressPlaceholder': '203.0.113.10 or 203.0.113.10:2222',
'cloudAgents.label': 'Display name',
'cloudAgents.labelPlaceholder': 'Optional, for example: Research server',
'cloudAgents.connect': 'Connect and discover',
'cloudAgents.refresh': 'Refresh connections',
'cloudAgents.delete': 'Delete connection',
'cloudAgents.online': 'Online',
'cloudAgents.offline': 'Unavailable',
'cloudAgents.agentCount': '{count} Agent CLIs',
'cloudAgents.transport.ssh-tunnel': 'SSH tunnel',
'cloudAgents.transport.ssh': 'SSH',
'cloudAgents.transport.bridge': 'HTTPS Bridge',
'cloudAgents.empty': 'No cloud Agent connections yet.',
'cloudAgents.agentDescription': 'Agent CLI connected through a private SSH bridge.',
'agentLocation.local': 'Local Agents',
'agentLocation.cloud': 'Cloud Agents',
'knowledgeBase.title': 'Knowledge bases',
'knowledgeBase.readyCount': '{ready} ready, {total} total',
'knowledgeBase.subtitle': 'Connect local document sources and verify the access available to Meldwork.',
@@ -622,9 +657,12 @@ export default {
'setupGuide.readyBody': 'Agents are ready. Create a group or open a direct chat to start.',
'agentDetail.skills': 'Skills',
'agentDetail.provider': 'Provider',
'agentDetail.location': 'Location',
'agentDetail.cloudLocation': '{server} · Version {version}',
'agentDetail.images': 'Images',
'agentDetail.soul': 'Soul',
'agentDetail.localSkills': 'Local skills',
'agentDetail.remoteSkills': 'Remote skills',
'agentDetail.showInSidebar': 'Show in sidebar',
'agentDetail.showInSidebarHint': 'Keep this ready Agent in the left navigation.',
'shortcut.title': 'Keyboard shortcuts',
@@ -762,6 +800,18 @@ export default {
'system.autoTimeout': 'Automatic discussion stopped after a runtime timeout before consensus.',
'system.runCircuitBreaker': 'The mandatory circuit breaker stopped this run after {maxAttempts} Agent attempts.',
'error.generic': 'The operation could not be completed.',
'error.cloudAgentBridgeAddress': 'Enter an HTTPS IP address for the server.',
'error.cloudAgentBridgeManifest': 'The server did not return a recognized Meldwork Agent Bridge manifest.',
'error.cloudAgentBridgeNetwork': 'The cloud Agent server could not be reached. Check the address and network.',
'error.cloudAgentBridgeProtocol': 'The cloud Agent server returned an unsupported response.',
'error.cloudAgentBridgeAuth': 'The cloud Agent server rejected the connection.',
'error.cloudAgentSshAddress': 'Enter an IP address or IP:port for SSH discovery.',
'error.cloudAgentSshUnavailable': 'Meldwork could not connect through your configured SSH access.',
'error.cloudAgentSshNoAgent': 'No supported Agent CLI was found through SSH.',
'error.cloudAgentSshProtocol': 'The remote Agent CLI returned an unsupported response.',
'error.cloudAgentSshPermission': 'This remote Agent currently supports read-only work only.',
'error.cloudAgentSshTunnelUnavailable': 'The SSH-local Agent Bridge could not be started.',
'error.cloudAgentSshTunnelProtocol': 'The SSH-local Agent Bridge returned an unsupported response.',
'error.bridge': 'The local desktop bridge is unavailable.',
'error.workspaceUnavailable': 'The local workspace is unavailable.',
'error.groupAgentRequired': 'Select at least one ready Agent.',
+53 -3
View File
@@ -105,6 +105,7 @@ export default {
'agent.incompatibleProtocol': '必需协议不可用',
'agent.detectedVersion': '版本 {version}',
'agent.localSkills': '{count} 个本地技能',
'agent.cloudSkills': '{count} 个远端技能',
'agent.skillsLoading': '正在读取本地技能',
'agent.skillsUnavailable': '无法读取技能数量',
'agent.images': '最多 {count} 张图片',
@@ -115,6 +116,7 @@ export default {
'agent.providerShort.anthropic': 'Anthropic 兼容',
'agent.providerShort.native': '本机 Provider',
'agent.providerShort.custom': '由 CLI 管理',
'agent.provider.connector': '远端 CLI 连接',
'agent.provider.compatible': '可单独配置 OpenAI 兼容 Provider',
'agent.provider.experimental': '独立外部 Provider 为实验性支持',
'agent.provider.responses': '使用兼容 Responses API 的 Provider',
@@ -401,9 +403,16 @@ export default {
'composer.send': '发送',
'composer.stop': '停止',
'composer.responseMode': '回复模式',
'composer.manual': '单轮回答',
'composer.manual': '单次回答',
'composer.concurrentResponses': '并发回复',
'composer.auto': '自动讨论',
'composer.concurrentResponsesHint': '所有 Agent 同时收到你的消息并各自独立回复,互不串接。',
'composer.auto': '顺序讨论',
'composer.sequentialHint': '按顺序逐一回复:Agent 轮流作答,可参考前一位的回答,至最大轮数为止。',
'composer.autoBeta': '自动讨论',
'composer.autoBetaHint': 'Beta:Agent 可相互 @ 拉入讨论,由模型自主编排各轮协作。',
'composer.autoBetaBadge': 'Beta',
'composer.autoBetaLockTitle': '自动讨论为 Beta 功能',
'composer.autoBetaLockBody': 'Agent 自主 @ 协作的真正自动讨论即将上线,敬请期待。',
'composer.smartTeam': '智能组队',
'composer.smartTeamOn': '开启',
'composer.smartTeamOff': '关闭',
@@ -471,6 +480,10 @@ export default {
'group.workspace': '工作目录',
'group.pickFolder': '选择目录',
'group.allowWrite': '允许 Agent 在此工作目录中创建和修改文件',
'group.agentLocationLocalHint': '在此设备上执行,可按群聊权限访问本地工作目录。',
'group.agentLocationCloudHint': '通过已连接服务器执行,当前仅支持只读任务。',
'group.cloudReadOnly': '云端 Agent 仅支持只读任务',
'group.cloudSelectionNotice': '云端 Agent 不会获得本地工作目录写入或附件访问权限。',
'group.create': '创建群聊',
'group.defaultName': 'Agent 群聊',
'group.createErrorAgents': '请至少选择一个可用 Agent。',
@@ -485,8 +498,9 @@ export default {
'settings.deleteConfirm': '确认删除',
'settings.deleteHint': '仅删除 Meldwork 中的该对话及已保存消息,不会删除 Agent 原生 CLI 会话。',
'systemSettings.title': '设置',
'systemSettings.subtitle': '管理本地 Agent、各自独立的 Provider 连接和知识库。',
'systemSettings.subtitle': '管理本机与云端 Agent、Provider 连接和知识库。',
'systemSettings.agents': 'Agent',
'systemSettings.cloudServers': '云服务器',
'systemSettings.providers': 'Provider',
'systemSettings.knowledgeBases': '知识库',
'systemSettings.providerForAgent': 'Provider',
@@ -494,6 +508,8 @@ export default {
'systemSettings.openAgentDetailDefault': 'Agent 详情',
'systemSettings.officialAgents': '官方 Agent',
'systemSettings.officialAgentsHint': '由 Meldwork 维护的内置 CLI 连接。',
'systemSettings.cloudAgents': '云 Agent',
'systemSettings.cloudAgentsHint': '从已连接云服务器发现的 Agent。',
'systemSettings.customAgents': '自定义 Agent',
'systemSettings.customAgentsHint': '在本机选择并管理的 CLI 连接。',
'systemSettings.agentCount': '{count} 个 Agent',
@@ -521,6 +537,25 @@ export default {
'customAgent.delete': '移除 Agent',
'customAgent.deleteConfirm': '确认移除',
'customAgent.empty': '尚未添加自定义 Agent。',
'cloudAgents.title': '云服务器',
'cloudAgents.subtitle': '输入服务器 IP。Meldwork 优先使用已配置的 SSH 访问,再尝试 HTTPS Agent Bridge。',
'cloudAgents.address': '服务器地址',
'cloudAgents.addressPlaceholder': '203.0.113.10 或 203.0.113.10:2222',
'cloudAgents.label': '显示名称',
'cloudAgents.labelPlaceholder': '可选,例如:研发云主机',
'cloudAgents.connect': '连接并探测',
'cloudAgents.refresh': '刷新连接',
'cloudAgents.delete': '删除连接',
'cloudAgents.online': '在线',
'cloudAgents.offline': '无法连接',
'cloudAgents.agentCount': '{count} 个 Agent CLI',
'cloudAgents.transport.ssh-tunnel': 'SSH 隧道',
'cloudAgents.transport.ssh': 'SSH',
'cloudAgents.transport.bridge': 'HTTPS Bridge',
'cloudAgents.empty': '还没有云 Agent 连接。',
'cloudAgents.agentDescription': '通过私有 SSH Bridge 连接的 Agent CLI。',
'agentLocation.local': '本机 Agent',
'agentLocation.cloud': '云端 Agent',
'knowledgeBase.title': '知识库',
'knowledgeBase.readyCount': '已就绪 {ready} / 共 {total}',
'knowledgeBase.subtitle': '连接本地文档源,并确认 Meldwork 当前具备的访问能力。',
@@ -622,9 +657,12 @@ export default {
'setupGuide.readyBody': 'Agent 已准备就绪,可以创建群聊或打开单聊开始任务。',
'agentDetail.skills': '技能',
'agentDetail.provider': 'Provider',
'agentDetail.location': '位置',
'agentDetail.cloudLocation': '{server} · 版本 {version}',
'agentDetail.images': '图片',
'agentDetail.soul': '角色',
'agentDetail.localSkills': '本地技能',
'agentDetail.remoteSkills': '远端技能',
'agentDetail.showInSidebar': '在侧边栏显示',
'agentDetail.showInSidebarHint': '将此可用 Agent 保留在左侧导航中。',
'shortcut.title': '快捷键',
@@ -762,6 +800,18 @@ export default {
'system.autoTimeout': '自动讨论在达成共识前因运行超时而停止。',
'system.runCircuitBreaker': '不可关闭的熔断器已在 {maxAttempts} 次 Agent 尝试后停止本次运行。',
'error.generic': '操作未能完成。',
'error.cloudAgentBridgeAddress': '请输入服务器的 HTTPS IP 地址。',
'error.cloudAgentBridgeManifest': '服务器未返回可识别的 Meldwork Agent Bridge 信息。',
'error.cloudAgentBridgeNetwork': '无法连接到云 Agent 服务器,请检查地址和网络。',
'error.cloudAgentBridgeProtocol': '云 Agent 服务器返回的数据格式不受支持。',
'error.cloudAgentBridgeAuth': '云 Agent 服务器拒绝了连接。',
'error.cloudAgentSshAddress': '请输入用于 SSH 探测的 IP 地址或 IP:端口。',
'error.cloudAgentSshUnavailable': '无法通过当前已配置的 SSH 访问连接云服务器。',
'error.cloudAgentSshNoAgent': '未能通过 SSH 找到受支持的 Agent CLI。',
'error.cloudAgentSshProtocol': '远端 Agent CLI 返回的数据格式不受支持。',
'error.cloudAgentSshPermission': '该远端 Agent 当前仅支持只读工作。',
'error.cloudAgentSshTunnelUnavailable': '无法启动 SSH 本地 Agent Bridge。',
'error.cloudAgentSshTunnelProtocol': 'SSH 本地 Agent Bridge 返回的数据格式不受支持。',
'error.bridge': '本地桌面桥接不可用。',
'error.workspaceUnavailable': '本地工作台不可用。',
'error.groupAgentRequired': '请至少选择一个可用 Agent。',
+47
View File
@@ -113,6 +113,7 @@
.sidebar.collapsed .brand-button span,
.sidebar.collapsed .new-group-button span,
.sidebar.collapsed .nav-heading,
.sidebar.collapsed .sidebar-agent-location-heading,
.sidebar.collapsed .sidebar-agent-main > span,
.sidebar.collapsed .sidebar-agent-new,
.sidebar.collapsed .direct-session-list,
@@ -289,6 +290,18 @@
margin: 2px 0 8px;
}
.sidebar-agent-location-group + .sidebar-agent-location-group {
margin-top: 10px;
}
.sidebar-agent-location-heading {
margin: 6px 8px 3px;
color: var(--faint);
font-size: 10px;
font-weight: 650;
line-height: 1.4;
}
.sidebar-agent-header {
height: 42px;
display: grid;
@@ -340,6 +353,25 @@
object-fit: contain;
}
.agent-logo-cloud {
position: relative;
width: 28px;
height: 28px;
display: block;
}
.agent-logo-cloud > svg {
position: absolute;
right: -4px;
bottom: -3px;
width: 13px;
height: 13px;
padding: 2px;
border-radius: 50%;
background: var(--sidebar);
color: var(--accent);
}
.sidebar-agent-main > span {
min-width: 0;
display: flex;
@@ -674,3 +706,18 @@
height: 15px;
color: var(--accent);
}
.group-avatar.stack {
position: relative;
}
.group-avatar.stack img {
position: absolute;
width: 17px;
height: 17px;
object-fit: contain;
}
.group-avatar.stack img:nth-child(1) { top: 0; left: 0; }
.group-avatar.stack img:nth-child(2) { right: 0; bottom: 0; }
.group-avatar.stack img:nth-child(3) { left: 0; bottom: 0; }
+48 -7
View File
@@ -11,19 +11,20 @@
}
.mode-segmented {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 2px;
display: inline-flex;
align-items: center;
gap: 1px;
padding: 2px;
border: 0;
border-radius: 999px;
background: var(--surface-hover);
justify-self: start;
}
.mode-segmented button {
min-width: 96px;
min-width: 0;
height: 26px;
padding: 0 7px;
padding: 0 10px;
border: 0;
border-radius: 999px;
background: transparent;
@@ -32,6 +33,9 @@
font-weight: 650;
white-space: nowrap;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
}
.mode-segmented button.active {
@@ -45,6 +49,37 @@
color: var(--accent);
}
.mode-segmented button[data-mode="auto-beta"]:hover:not(:disabled):not(.active) {
background: transparent;
color: var(--muted);
}
.mode-segmented button[data-mode="auto-beta"].active {
background: var(--accent);
color: var(--on-accent, #fff);
border-color: transparent;
}
.mode-segmented button[data-mode="auto-beta"].active .mode-beta-badge {
background: var(--surface-raised);
color: var(--accent);
}
.mode-beta-badge {
display: inline-flex;
align-items: center;
height: 14px;
padding: 0 5px;
margin-left: 4px;
border-radius: 999px;
background: var(--accent);
color: var(--on-accent, #fff);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.02em;
line-height: 1;
}
.round-settings-control {
position: relative;
display: flex;
@@ -1162,14 +1197,20 @@
}
.mode-segmented button {
min-width: 86px;
min-width: 0;
height: 32px;
padding-inline: 4px;
padding-inline: 6px;
font-size: 10px;
line-height: 1.05;
white-space: normal;
}
.mode-beta-badge {
height: 12px;
padding: 0 4px;
font-size: 8px;
}
.round-settings-trigger {
min-width: 68px;
height: 28px;
+72 -5
View File
@@ -217,6 +217,35 @@
gap: 7px;
}
.agent-choice-location-groups {
display: grid;
gap: 12px;
}
.agent-choice-location-group {
min-width: 0;
}
.agent-choice-location-group > header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
}
.agent-choice-location-group > header strong {
color: var(--text-soft);
font-size: 12px;
}
.agent-choice-location-group > header small {
color: var(--muted);
font-size: 10px;
line-height: 1.4;
text-align: right;
}
@container (min-width: 580px) {
.agent-choice-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -257,12 +286,31 @@
pointer-events: none;
}
.agent-choice img {
.agent-choice-logo {
position: relative;
width: 27px;
height: 27px;
display: block;
}
.agent-choice-logo img {
width: 27px;
height: 27px;
object-fit: contain;
}
.agent-choice-logo.cloud > svg {
position: absolute;
right: -4px;
bottom: -3px;
width: 13px;
height: 13px;
padding: 2px;
border-radius: 50%;
background: var(--surface-raised);
color: var(--accent);
}
.agent-choice span {
overflow: hidden;
font-size: 12px;
@@ -270,11 +318,11 @@
white-space: nowrap;
}
.agent-choice svg {
.agent-choice > .agent-choice-check {
color: transparent;
}
.agent-choice.selected svg {
.agent-choice.selected > .agent-choice-check {
color: var(--accent);
}
@@ -303,12 +351,13 @@
background: color-mix(in srgb, var(--accent-soft) 64%, var(--surface-raised));
}
.settings-agent-choice img {
.settings-agent-choice .agent-choice-logo,
.settings-agent-choice .agent-choice-logo img {
width: 24px;
height: 24px;
}
.settings-agent-choice svg {
.settings-agent-choice > .agent-choice-check {
width: 15px;
height: 15px;
}
@@ -362,6 +411,24 @@ fieldset:disabled .agent-choice {
transform: translateX(16px);
}
.switch-row.disabled {
color: var(--muted);
cursor: not-allowed;
}
.switch-row.disabled .switch-control {
opacity: 0.55;
}
.group-cloud-notice {
margin: -2px 0 0;
padding-left: 10px;
border-left: 2px solid var(--border-strong);
color: var(--muted);
font-size: 11px;
line-height: 1.5;
}
.form-error {
margin: -2px 0 0;
padding: 9px 10px;
+20 -1
View File
@@ -78,13 +78,32 @@
animation: agent-detail-in 0.18s ease-out both;
}
.agent-detail-hero img {
.agent-detail-logo {
position: relative;
width: 64px;
height: 64px;
display: block;
}
.agent-detail-logo img {
width: 64px;
height: 64px;
object-fit: contain;
background: transparent;
}
.agent-detail-logo.cloud > svg {
position: absolute;
right: -5px;
bottom: -4px;
width: 21px;
height: 21px;
padding: 4px;
border-radius: 50%;
background: var(--surface-raised);
color: var(--accent);
}
.agent-detail-hero p {
margin: 7px 0 0;
color: var(--muted);
+25
View File
@@ -366,6 +366,31 @@
align-items: start;
}
.settings-agent-logo {
position: relative;
width: 38px;
height: 38px;
display: block;
}
.settings-agent-logo > img {
width: 38px;
height: 38px;
object-fit: contain;
}
.settings-agent-logo.cloud > svg {
position: absolute;
right: -4px;
bottom: -3px;
width: 15px;
height: 15px;
padding: 2px;
border-radius: 50%;
background: var(--surface-raised);
color: var(--accent);
}
.settings-agent-card .agent-card-copy {
gap: 6px;
}
+114
View File
@@ -73,6 +73,120 @@
padding: 18px 0 40px;
}
.cloud-agent-bridge-panel {
border-top: 1px solid var(--border);
}
.cloud-agent-bridge-panel .manager-toolbar {
align-items: flex-start;
}
.cloud-agent-bridge-panel h2,
.cloud-agent-bridge-panel p {
margin: 0;
}
.cloud-agent-bridge-panel h2 {
color: var(--text);
font-size: 18px;
}
.cloud-agent-bridge-panel .manager-toolbar p {
margin-top: 5px;
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
.cloud-agent-connect-form {
display: grid;
grid-template-columns: minmax(220px, 1.5fr) minmax(150px, 1fr) auto;
align-items: end;
gap: 10px;
margin: 14px 0;
}
.cloud-agent-connect-form label {
display: grid;
gap: 5px;
color: var(--muted);
font-size: 11px;
}
.cloud-agent-connect-form input {
min-height: 36px;
padding: 0 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface-raised);
color: var(--text);
}
.cloud-agent-connect-form input:focus-visible {
border-color: var(--accent);
outline: 2px solid var(--accent-soft);
outline-offset: 1px;
}
.cloud-agent-bridge-error {
margin: 8px 0;
color: var(--danger, #b34d4d);
font-size: 12px;
}
.cloud-agent-bridge-list {
display: grid;
gap: 7px;
}
.cloud-agent-bridge-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 11px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface-raised);
}
.cloud-agent-bridge-item-main {
min-width: 0;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 7px 12px;
}
.cloud-agent-bridge-item-main > strong {
color: var(--text);
font-size: 13px;
}
.cloud-agent-bridge-agent-list {
width: 100%;
margin: -1px 0 0;
overflow: hidden;
color: var(--text-soft) !important;
font-size: 11px !important;
text-overflow: ellipsis;
white-space: nowrap;
}
.cloud-agent-bridge-item-main > span,
.cloud-agent-bridge-item-main > small {
color: var(--muted);
font-size: 11px;
}
.bridge-online { color: var(--success, #4f7564) !important; }
.bridge-offline { color: var(--danger, #b34d4d) !important; }
.cloud-agent-bridge-empty {
color: var(--muted);
font-size: 12px;
}
.provider-settings-panel {
--provider-success: #4f7564;
--provider-success-soft: #e7efeb;
+3 -2
View File
@@ -47,7 +47,7 @@ export function createWorkspaceControllers({
selectGroup: app.selectGroup,
selectedGroupId: app.selectedGroupId,
sidebarAgentSessionListId: conversationNavigation.sidebarAgentSessionListId,
sidebarAgents: agentCatalog.sidebarAgents,
sidebarAgentGroups: agentCatalog.sidebarAgentGroups,
sidebarAgentsCollapsed: app.sidebarAgentsCollapsed,
sidebarGroupsCollapsed: app.sidebarGroupsCollapsed,
sidebarCollapsed: app.sidebarCollapsed,
@@ -71,6 +71,7 @@ export function createWorkspaceControllers({
agentSkillLabel: agentSkills.agentSkillLabel,
agentState: agentCatalog.agentState,
cancelInstall: agentManagement.cancelInstall,
cloudAgentBridges: app.cloudAgentBridges,
focusedAgentKind: app.focusedAgentKind,
formError: app.formError,
installConfirmKind: app.installConfirmKind,
@@ -138,7 +139,7 @@ export function createWorkspaceControllers({
openProvider: app.openProvider,
pickGroupDirectory: conversationActions.pickGroupDirectory,
providerSummaryLabel: providerSettings.providerSummaryLabel,
readyAgents: agentCatalog.readyAgents,
readyAgentGroups: agentCatalog.readyAgentGroups,
requestDeleteConfirmation: conversationActions.requestDeleteConfirmation,
requestInstall: agentManagement.requestInstall,
saveGroupSettings: conversationActions.saveGroupSettings,