mirror of
https://github.com/Ryder-Sun/Meldwork.git
synced 2026-11-07 06:56:03 -10:00
fix cloud agents chat continuity and avatars
This commit is contained in:
1 parent
da0108abb1
commit
7752489936
49 files changed
+3077
-122
No files matched your search
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
|
||||
@@ -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
@@ -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')
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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。',
|
||||
|
||||
@@ -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; }
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user