feat: checkpoint current RoundRelay UX and ignore docs

Keep the current local multi-agent and provider work in one checkpoint, add project-level ignore rules for docs/ and documentation/, and remove already tracked documentation from the index. No fresh verification was run in this checkpoint; the previous baseline had frontend, desktop, build, and pack checks passing.
This commit is contained in:
Ryder Sun committed 2026-07-31 10:08:59 +08:00
1 parent 897496f52d
commit dadcdadd19
35 files changed
+3513 -1978

No files matched your search

+4
View File
@@ -20,6 +20,10 @@ release/
*.blockmap
latest*.yml
# Local documentation drafts
/docs/
/documentation/
# Test and coverage output
coverage/
frontend/coverage/
+36 -24
View File
@@ -4,47 +4,59 @@
# Meldwork
**Put your local agents to work, together.**
**Bring the local Agent CLIs already on your machine into one persistent workspace.**
Meldwork is a local-first desktop workspace where multiple AI Agent CLIs can collaborate on the same task. It discovers the Agents already available on your computer, keeps their conversations and native sessions persistent, and coordinates manual or bounded automatic discussions without requiring a hosted orchestration service.
Meldwork is building an open, local-first work system for agents across vendors. The current MVP brings multiple local AI Agent CLIs into the same local conversation and working directory, discovers the Agents already available on your computer, keeps conversations persistent, preserves resumable native sessions where compatible CLIs expose them, and coordinates manual or bounded automatic discussions without requiring a hosted orchestration service.
The long-term product direction is open-source multi-agent collaboration. This repository is still `UNLICENSED`; the public open-source launch must not happen until a project license is selected and added.
The long-term product direction is an open-source, local-first work system for agents across vendors. This repository is still `UNLICENSED`; the public open-source launch must not happen until a project license is selected and added.
## Product Direction
The target product promise is **Different agents. Work you can verify.** It describes the evidence-and-acceptance loop Meldwork intends to build, not a claim that the current MVP already verifies Agent output.
The current MVP proves local Agent discovery, guarded execution, persistent conversations, and native-session continuity where upstream CLIs support it. The next milestone is not a larger adapter count; it is an outcome loop that turns Agent activity into work a user can inspect and adopt:
```text
Task -> Runs -> Artifacts -> Evidence -> Human acceptance and adoption
```
Meldwork should use one Agent by default and add independent attempts or adversarial review only when task risk or uncertainty justifies the extra time and cost. Cloud Agents, Channels, Workflows, and team governance remain roadmap options until this loop demonstrates repeatable value.
## Why Meldwork
- **One workspace for different Agents.** Bring Codex, Claude Code, Kimi Code, MiMo Code, and other local CLIs into direct or group conversations.
- **Persistent collaborative context.** Continue the same task with one native session per Agent and conversation; resumed prompts add only the group messages that Agent has not already seen.
- **Persistent collaborative context.** For compatible CLI versions that expose resumable sessions, continue with one native session per Agent and conversation; resumed prompts add only the group messages that Agent has not already seen.
- **Local orchestration with explicit boundaries.** Agent execution, groups, messages, and orchestration stay on the user's machine. Provider traffic still follows each selected Agent's configuration.
- **Narrow desktop security boundary.** The renderer never receives executable paths, provider credentials, unrestricted shell access, attachment paths, or native session references.
- **Narrow desktop security boundary.** Executable paths, attachment paths, and native session references stay in the Electron main process. Stored Provider credentials are never returned to the renderer after saving; newly entered credentials cross only the validated Provider IPC. The renderer has no unrestricted shell access.
## Core Capabilities
- Discover installed Agent CLIs without exposing executable paths to the renderer.
- Create multiple persistent direct conversations per Agent, then rename or delete each local conversation independently.
- Run one response at a time or an automatic discussion with a user-selected 1-10 round limit (6 by default).
- Preserve one logical native session per Agent and conversation, with automatic migration from older topic-scoped references.
- Preserve one logical native session per Agent and conversation where the selected CLI supports resume, with automatic migration from older topic-scoped references.
- Show per-Agent queued/running/completed state, automatic round progress, background direct-chat completion markers, and local desktop notifications.
- Reference validated local Skills with `@` and attach PNG or JPEG images with capability checks.
- Request read-only modes by default where an Agent can enforce them, with explicit write opt-in only when the adapter can preserve the workspace boundary.
- Install supported missing Agents through fixed, allowlisted flows.
- Request read-only modes by default where an Agent can enforce them, with explicit write opt-in only when the adapter can preserve the workspace boundary. These are adapter-level controls, not an operating-system sandbox.
- Install supported missing Agents through fixed, allowlisted recipes. Upstream packages and scripts are not yet digest- or signature-pinned.
- Store compatible provider credentials with operating-system-backed secure storage.
## Supported Agents
| Agent | Local detection | Guided installation | Provider and permission behavior |
| --- | --- | --- | --- |
| Codex | Yes | macOS and Windows | Uses its existing Responses-compatible configuration |
| Hermes | Yes | macOS and Windows | Supports a shared OpenAI-compatible Provider |
| OpenClaw | Yes | macOS and Windows | Supports a managed OpenAI-compatible Provider |
| WorkBuddy | Yes | macOS and Windows | Experimental shared Provider support |
| Kimi Code | Yes | macOS and Windows | Uses its native configuration and ACP plan mode for read-only work |
| MiMo Code | Yes | macOS and Windows | Uses native configuration; Meldwork invokes the `plan` Agent and does not enable the global permission-bypass flag |
| Claude Code | Yes | macOS and Windows | Uses its existing Anthropic-compatible configuration |
| Gemini CLI | Yes | macOS and Windows | Uses its existing authentication and Provider settings |
| OpenCode | Yes | macOS and Windows | Uses its existing Provider settings |
| Qwen Code | Yes | macOS and Windows | Supports a shared OpenAI-compatible Provider |
| Agent | Adapter implemented | Installer recipes | Release validation | Provider and permission behavior |
| --- | --- | --- | --- | --- |
| Codex | Yes | macOS and Windows | Pending target-device matrix | Uses its existing Responses-compatible configuration |
| Hermes | Yes | macOS and Windows | Pending target-device matrix | Supports a shared OpenAI-compatible Provider |
| OpenClaw | Yes | macOS and Windows | Pending target-device matrix | Supports a managed OpenAI-compatible Provider |
| WorkBuddy | Yes | macOS and Windows | Pending target-device matrix | Experimental shared Provider support |
| Kimi Code | Yes | macOS and Windows | Pending target-device matrix | Uses its native configuration and ACP plan mode for read-only work |
| MiMo Code | Yes | macOS and Windows | Pending target-device matrix | Uses native configuration; Meldwork invokes the `plan` Agent and does not enable the global permission-bypass flag |
| Claude Code | Yes | macOS and Windows | Pending target-device matrix | Uses its existing Anthropic-compatible configuration |
| Gemini CLI | Yes | macOS and Windows | Pending target-device matrix | Uses its existing authentication and Provider settings |
| OpenCode | Yes | macOS and Windows | Pending target-device matrix | Uses its existing Provider settings |
| Qwen Code | Yes | macOS and Windows | Pending target-device matrix | Supports a shared OpenAI-compatible Provider |
Availability still depends on each upstream CLI, operating-system support, authentication, model configuration, and version compatibility.
These entries describe implemented adapters and installer recipes, not release-certified compatibility. Availability still depends on each upstream CLI, operating-system support, authentication, model configuration, and version compatibility. Live-Agent compatibility and real-device Windows validation remain pending.
## Privacy And Architecture
@@ -55,7 +67,7 @@ Electron main process
|-- Agent discovery and execution
|-- local group and message storage
|-- local Skill discovery and validation
|-- private image attachment storage and previews
|-- app-owned local image attachment storage and previews
|-- provider credential storage
`-- user-authorized provider and installer network access
```
@@ -106,6 +118,6 @@ The architecture review entry point is [`documentation/architecture.md`](documen
## Release Status
Meldwork is under active development. Local discovery, persistent direct/group conversations, guarded heterogeneous-Agent execution, automatic discussion, `@` Skill references, image attachments, provider storage, frontend tests, desktop tests, and macOS package generation are implemented.
Meldwork is under active development. Local discovery, persistent direct/group conversations, guarded heterogeneous-Agent execution, automatic discussion, `@` Skill references, image attachments, provider storage, frontend tests, desktop tests, and macOS packaging configuration are implemented.
Before a public open-source release, the project still needs a selected license, real-device Windows validation, a target-device Agent compatibility matrix, and signed/notarized macOS distribution.
Before a public open-source release, the project still needs a selected license, a target-device Agent compatibility matrix, and signed/notarized macOS distribution. Windows support should remain implementation-level until it passes a real-device release matrix.
+115
View File
@@ -0,0 +1,115 @@
const fs = require('node:fs')
const path = require('node:path')
const OUTPUT_DIRECTORY = '.meldwork-output'
const MAX_OUTPUT_ATTACHMENTS = 4
const MAX_SCANNED_ENTRIES = 512
const SUPPORTED_EXTENSIONS = new Set([
'.png', '.jpg', '.jpeg', '.mp3', '.wav', '.m4a', '.mp4', '.mov', '.webm',
])
function isInside(root, candidate) {
const relative = path.relative(root, candidate)
return Boolean(relative && relative !== '..'
&& !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative))
}
function safeWorkdir(workdir) {
if (typeof workdir !== 'string' || !path.isAbsolute(workdir) || workdir.length > 4096) return ''
try {
const stat = fs.statSync(workdir)
return stat.isDirectory() ? fs.realpathSync(workdir) : ''
} catch {
return ''
}
}
function safeOutputDirectory(workdirRealPath) {
if (!workdirRealPath) return ''
const outputPath = path.join(workdirRealPath, OUTPUT_DIRECTORY)
try {
const stat = fs.lstatSync(outputPath)
if (stat.isSymbolicLink() || !stat.isDirectory()) return ''
const realPath = fs.realpathSync(outputPath)
return isInside(workdirRealPath, realPath) ? realPath : ''
} catch {
return ''
}
}
function outputFiles(outputRealPath) {
if (!outputRealPath) return []
let entries
try { entries = fs.readdirSync(outputRealPath, { withFileTypes: true }) } catch { return [] }
const files = []
for (const entry of entries.slice(0, MAX_SCANNED_ENTRIES)) {
if (!entry.isFile() || entry.isSymbolicLink()) continue
if (!SUPPORTED_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) continue
const filename = path.join(outputRealPath, entry.name)
try {
const stat = fs.lstatSync(filename)
if (!stat.isFile() || stat.isSymbolicLink()) continue
const realPath = fs.realpathSync(filename)
if (!isInside(outputRealPath, realPath)) continue
files.push({
name: entry.name,
path: realPath,
size: stat.size,
mtimeMs: stat.mtimeMs,
ctimeMs: stat.ctimeMs,
})
} catch { /* a changing output file is skipped until a later run */ }
}
return files
}
function captureAgentOutputState(workdir) {
const workdirRealPath = safeWorkdir(workdir)
const files = {}
for (const file of outputFiles(safeOutputDirectory(workdirRealPath))) {
files[file.name] = {
size: file.size,
mtimeMs: file.mtimeMs,
ctimeMs: file.ctimeMs,
}
}
return { workdirRealPath, files }
}
function sameFileState(left, right) {
return left && right
&& left.size === right.size
&& left.mtimeMs === right.mtimeMs
&& left.ctimeMs === right.ctimeMs
}
function importAgentOutputs(input, attachmentStore) {
if (!input?.baseline || !attachmentStore || typeof attachmentStore.importFile !== 'function') return []
const workdirRealPath = safeWorkdir(input.workdir)
if (!workdirRealPath || input.baseline.workdirRealPath !== workdirRealPath) return []
const startedAt = Number(input.startedAt)
if (!Number.isFinite(startedAt) || startedAt <= 0) return []
const baselineFiles = input.baseline.files && typeof input.baseline.files === 'object'
&& !Array.isArray(input.baseline.files)
? input.baseline.files
: {}
const candidates = outputFiles(safeOutputDirectory(workdirRealPath))
.filter(file => !sameFileState(baselineFiles[file.name], file))
.filter(file => Math.max(file.mtimeMs, file.ctimeMs) >= startedAt - 2000)
.sort((left, right) => (
Math.max(right.mtimeMs, right.ctimeMs) - Math.max(left.mtimeMs, left.ctimeMs)
|| left.name.localeCompare(right.name)
))
const imported = []
for (const file of candidates) {
if (imported.length >= MAX_OUTPUT_ATTACHMENTS) break
try { imported.push(attachmentStore.importFile(file.path)) } catch { /* invalid media is ignored */ }
}
return imported
}
module.exports = {
captureAgentOutputState,
importAgentOutputs,
}
+47 -14
View File
@@ -3,21 +3,33 @@ const fs = require('node:fs')
const path = require('node:path')
const STORE_VERSION = 1
const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024
const MAX_ATTACHMENT_BYTES = 128 * 1024 * 1024
const MAX_ATTACHMENTS = 4
const MAX_METADATA_BYTES = 64 * 1024
const DIRECTORY_MODE = 0o700
const FILE_MODE = 0o600
const IMAGE_TYPES = Object.freeze([
Object.freeze({ mimeType: 'image/png', extension: 'png' }),
Object.freeze({ mimeType: 'image/jpeg', extension: 'jpg' }),
const ATTACHMENT_TYPES = Object.freeze([
Object.freeze({ mimeType: 'image/png', extension: 'png', maxBytes: 8 * 1024 * 1024, storageBase: 'image' }),
Object.freeze({ mimeType: 'image/jpeg', extension: 'jpg', maxBytes: 8 * 1024 * 1024, storageBase: 'image' }),
Object.freeze({ mimeType: 'audio/mpeg', extension: 'mp3', maxBytes: 32 * 1024 * 1024, storageBase: 'media' }),
Object.freeze({ mimeType: 'audio/wav', extension: 'wav', maxBytes: 64 * 1024 * 1024, storageBase: 'media' }),
Object.freeze({ mimeType: 'audio/mp4', extension: 'm4a', maxBytes: 64 * 1024 * 1024, storageBase: 'media' }),
Object.freeze({ mimeType: 'video/mp4', extension: 'mp4', maxBytes: MAX_ATTACHMENT_BYTES, storageBase: 'media' }),
Object.freeze({ mimeType: 'video/quicktime', extension: 'mov', maxBytes: MAX_ATTACHMENT_BYTES, storageBase: 'media' }),
Object.freeze({ mimeType: 'video/webm', extension: 'webm', maxBytes: MAX_ATTACHMENT_BYTES, storageBase: 'media' }),
])
const TYPE_BY_MIME = new Map(IMAGE_TYPES.map(type => [type.mimeType, type]))
const TYPE_BY_MIME = new Map(ATTACHMENT_TYPES.map(type => [type.mimeType, type]))
const TYPE_BY_EXTENSION = new Map([
['png', TYPE_BY_MIME.get('image/png')],
['jpg', TYPE_BY_MIME.get('image/jpeg')],
['jpeg', TYPE_BY_MIME.get('image/jpeg')],
['mp3', TYPE_BY_MIME.get('audio/mpeg')],
['wav', TYPE_BY_MIME.get('audio/wav')],
['m4a', TYPE_BY_MIME.get('audio/mp4')],
['mp4', TYPE_BY_MIME.get('video/mp4')],
['mov', TYPE_BY_MIME.get('video/quicktime')],
['webm', TYPE_BY_MIME.get('video/webm')],
])
function attachmentError(code) {
@@ -77,7 +89,7 @@ function isInside(root, candidate) {
&& relative !== '..' && !path.isAbsolute(relative))
}
function detectImageType(bytes) {
function detectAttachmentType(bytes) {
if (bytes.length >= 8
&& bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
return TYPE_BY_MIME.get('image/png')
@@ -85,6 +97,26 @@ function detectImageType(bytes) {
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
return TYPE_BY_MIME.get('image/jpeg')
}
if (bytes.length >= 3 && (bytes.subarray(0, 3).toString('ascii') === 'ID3'
|| (bytes[0] === 0xff && (bytes[1] & 0xe0) === 0xe0))) {
return TYPE_BY_MIME.get('audio/mpeg')
}
if (bytes.length >= 12 && bytes.subarray(0, 4).toString('ascii') === 'RIFF'
&& bytes.subarray(8, 12).toString('ascii') === 'WAVE') {
return TYPE_BY_MIME.get('audio/wav')
}
if (bytes.length >= 12 && bytes.subarray(4, 8).toString('ascii') === 'ftyp') {
const brand = bytes.subarray(8, 12).toString('ascii')
if (['M4A ', 'M4B ', 'F4A ', 'f4a '].includes(brand)) return TYPE_BY_MIME.get('audio/mp4')
if (brand === 'qt ') return TYPE_BY_MIME.get('video/quicktime')
if (/^(?:iso[2-9m]|isom|avc1|mp4[12]|M4V |dash)$/.test(brand)) {
return TYPE_BY_MIME.get('video/mp4')
}
}
if (bytes.length >= 8 && bytes.subarray(0, 4).equals(Buffer.from([0x1a, 0x45, 0xdf, 0xa3]))
&& bytes.subarray(0, Math.min(bytes.length, 128)).includes(Buffer.from('webm'))) {
return TYPE_BY_MIME.get('video/webm')
}
return null
}
@@ -131,8 +163,8 @@ function toBuffer(value) {
return Buffer.from(new Uint8Array(value))
}
function validateImage(bytes, name, mimeType, requireMimeType) {
const actual = detectImageType(bytes)
function validateAttachment(bytes, name, mimeType, requireMimeType) {
const actual = detectAttachmentType(bytes)
if (!actual) fail('LOCAL_ATTACHMENT_TYPE_UNSUPPORTED')
const declaredMime = declaredMimeType(mimeType)
if (requireMimeType && !TYPE_BY_MIME.has(declaredMime)) {
@@ -145,6 +177,7 @@ function validateImage(bytes, name, mimeType, requireMimeType) {
if (nameType && nameType.mimeType !== actual.mimeType) {
fail('LOCAL_ATTACHMENT_TYPE_MISMATCH')
}
if (bytes.length > actual.maxBytes) fail('LOCAL_ATTACHMENT_TOO_LARGE')
return actual
}
@@ -367,7 +400,7 @@ class AttachmentStore {
importBytes(bytes, name, mimeType, requireMimeType) {
this.assertRoot()
if (bytes.length > MAX_ATTACHMENT_BYTES) fail('LOCAL_ATTACHMENT_TOO_LARGE')
const type = validateImage(bytes, name, mimeType, requireMimeType)
const type = validateAttachment(bytes, name, mimeType, requireMimeType)
let id
try { id = normalizeId(this.createId()) } catch (error) {
if (isAttachmentError(error)) throw error
@@ -390,7 +423,7 @@ class AttachmentStore {
if (fs.existsSync(finalDirectory)) fail('LOCAL_ATTACHMENT_ID_CONFLICT')
temporaryDirectory = fs.mkdtempSync(path.join(this.rootPath, '.import-'))
fs.chmodSync(temporaryDirectory, DIRECTORY_MODE)
writePrivateFile(path.join(temporaryDirectory, `image.${type.extension}`), bytes)
writePrivateFile(path.join(temporaryDirectory, `${type.storageBase}.${type.extension}`), bytes)
writePrivateFile(
path.join(temporaryDirectory, 'metadata.json'),
Buffer.from(JSON.stringify(document), 'utf8'),
@@ -447,20 +480,20 @@ class AttachmentStore {
if (!type || sanitizeName(document.name, type) !== document.name) {
fail('LOCAL_ATTACHMENT_TAMPERED')
}
const imagePath = path.join(directory, `image.${type.extension}`)
const contentPath = path.join(directory, `${type.storageBase}.${type.extension}`)
const bytes = this.readStoredFile(
imagePath,
contentPath,
'LOCAL_ATTACHMENT_FILE_MISSING',
MAX_ATTACHMENT_BYTES,
)
if (bytes.length !== document.size
|| detectImageType(bytes)?.mimeType !== document.mimeType
|| detectAttachmentType(bytes)?.mimeType !== document.mimeType
|| crypto.createHash('sha256').update(bytes).digest('hex') !== document.checksum) {
fail('LOCAL_ATTACHMENT_TAMPERED')
}
return {
directory,
path: imagePath,
path: contentPath,
bytes,
metadata: {
id: document.id,
+107 -6
View File
@@ -3,6 +3,7 @@ const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { Readable, Writable } = require('node:stream')
const { StringDecoder } = require('node:string_decoder')
const { promisify } = require('node:util')
const execFileAsync = promisify(execFile)
@@ -582,6 +583,80 @@ function parseCodexOutput(stdout) {
return { text: texts.join('\n').trim(), sessionRef }
}
function codexProgressTitle(item) {
const type = String(item?.type || '').toLowerCase()
const descriptor = [type, item?.name, item?.tool, item?.command]
.map(value => String(value || '').toLowerCase())
.join(' ')
if (/image[_ -]?(?:generation|gen)|imagegen|generate[_ -]?image/.test(descriptor)) return 'image_generation'
if (/audio[_ -]?(?:generation|gen)|generate[_ -]?audio|text[_ -]?to[_ -]?speech/.test(descriptor)) {
return 'audio_generation'
}
if (/video[_ -]?(?:generation|gen)|generate[_ -]?video/.test(descriptor)) return 'video_generation'
if (type === 'reasoning' || /\breasoning\b/.test(descriptor)) return 'reasoning'
if (/apply_patch|write[_ -]?file|edit[_ -]?file|create[_ -]?file|\b(?:mkdir|tee|cp|mv)\b/.test(descriptor)) {
return 'write_file'
}
if (/\b(?:rg|grep|find|search|glob)\b/.test(descriptor)) return 'search'
if (/read[_ -]?file|\b(?:cat|sed|head|tail|ls|stat|file|ffprobe)\b|git (?:show|diff)/.test(descriptor)) {
return 'read_file'
}
if (/tool|mcp/.test(type)) return 'tool'
return 'process'
}
function codexProgressEvent(event) {
if (event?.type === 'turn.started') {
return { id: 'turn', title: 'process', status: 'in_progress' }
}
if (event?.type === 'turn.completed') {
return { id: 'turn', title: 'process', status: 'completed' }
}
if (!['item.started', 'item.completed'].includes(event?.type)
|| !event.item || event.item.type === 'agent_message') {
return null
}
const rawId = String(event.item.id || '')
const id = /^[A-Za-z0-9._:-]{1,100}$/.test(rawId) ? rawId : ''
const failed = event.item.status === 'failed'
|| (Number.isInteger(event.item.exit_code) && event.item.exit_code !== 0)
return {
...(id ? { id } : {}),
title: codexProgressTitle(event.item),
status: failed
? 'failed'
: event.type === 'item.started' ? 'in_progress' : 'completed',
}
}
function createJsonLineParser(onEvent) {
const decoder = new StringDecoder('utf8')
let pending = ''
const consume = (flush = false) => {
const lines = pending.split(/\r?\n/)
pending = flush ? '' : (lines.pop() || '')
if (flush && lines.length === 1 && !lines[0]) return
for (const line of lines) {
if (!line.trim()) continue
try { onEvent(JSON.parse(line)) } catch { /* ignore non-JSON diagnostics */ }
}
if (flush && pending.trim()) {
try { onEvent(JSON.parse(pending)) } catch { /* ignore incomplete diagnostics */ }
pending = ''
}
}
return {
write(chunk) {
pending += decoder.write(chunk)
consume(false)
},
end() {
pending += decoder.end()
consume(true)
},
}
}
function parseMimoOutput(stdout) {
let sessionRef = ''
const texts = []
@@ -1325,22 +1400,46 @@ async function runAgent(agent, prompt, workdir, options = {}) {
let forceKillTimeout
let forceSettleTimeout
const progress = []
const progressIndexes = new Map()
let anonymousProgressId = 0
const emitProgress = (input) => {
const event = {
...(input?.id ? { id: String(input.id) } : {}),
title: String(input?.title || 'process'),
status: ['completed', 'failed', 'in_progress'].includes(input?.status)
? input.status
: 'completed',
}
const progressId = event.id || `anonymous-${++anonymousProgressId}`
const existingIndex = progressIndexes.get(progressId)
if (existingIndex != null) {
progress[existingIndex] = event
} else {
if (progress.length >= MAX_PROGRESS_STEPS) return
progressIndexes.set(progressId, progress.length)
progress.push(event)
}
try { options.onProgress?.(event) } catch { /* progress is best-effort */ }
}
const hermesProgressBuffers = { stdout: '', stderr: '' }
const hermesProgressDecoders = { stdout: new TextDecoder(), stderr: new TextDecoder() }
const emitHermesProgress = (source, chunk, flush = false) => {
if (agent.kind !== 'hermes' || progress.length >= MAX_PROGRESS_STEPS) return
if (agent.kind !== 'hermes') return
const decoder = hermesProgressDecoders[source]
hermesProgressBuffers[source] += decoder.decode(chunk || undefined, { stream: !flush })
const lines = hermesProgressBuffers[source].split(/\r?\n/)
hermesProgressBuffers[source] = flush ? '' : (lines.pop() || '')
for (const line of lines) {
if (!/^┊\s*review diff$/i.test(stripAnsi(line).trim())) continue
const event = { title: 'write_file', status: 'completed' }
progress.push(event)
try { options.onProgress?.(event) } catch { /* progress is best-effort */ }
if (progress.length >= MAX_PROGRESS_STEPS) break
emitProgress({ title: 'write_file', status: 'completed' })
}
}
const codexProgressParser = agent.kind === 'codex'
? createJsonLineParser((event) => {
const progressEvent = codexProgressEvent(event)
if (progressEvent) emitProgress(progressEvent)
})
: null
const finish = (callback) => {
if (settled) return
settled = true
@@ -1396,6 +1495,7 @@ async function runAgent(agent, prompt, workdir, options = {}) {
child.stdout.on('data', (chunk) => {
stdoutBytes += chunk.length
if (stdoutBytes <= 10 * 1024 * 1024) stdout.push(chunk)
codexProgressParser?.write(chunk)
emitHermesProgress('stdout', chunk)
})
child.stderr.on('data', (chunk) => {
@@ -1415,6 +1515,7 @@ async function runAgent(agent, prompt, workdir, options = {}) {
void (async () => {
emitHermesProgress('stdout', null, true)
emitHermesProgress('stderr', null, true)
codexProgressParser?.end()
if (stopRequested || options.signal?.aborted) {
reject(new Error('LOCAL_AGENT_EXECUTION_STOPPED'))
return
@@ -1465,8 +1566,8 @@ async function runAgent(agent, prompt, workdir, options = {}) {
}
const storedResponse = typeof finalResponse === 'string' ? finalResponse.trim() : ''
if (storedResponse) result.text = storedResponse
if (progress.length) result.progress = progress
}
if (progress.length) result.progress = progress
const redactedText = redactChildSecrets(result.text, childEnv)
if (!redactedText) {
reject(agentExecutionError('LOCAL_AGENT_EMPTY_RESPONSE'))
+146 -29
View File
@@ -22,17 +22,25 @@ const DEFAULT_AUTO_ROUNDS = 6
const MAX_AUTO_ROUNDS = 10
const MAX_MESSAGE_ATTACHMENTS = 4
const MAX_SKILL_HINTS = 4
const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024
const MAX_ATTACHMENT_BYTES = 128 * 1024 * 1024
const STABLE_USER_TURNS_PER_EDGE = 3
const STABLE_USER_TURN_TEXT_LIMIT = 700
const RECENT_TRANSCRIPT_MESSAGE_LIMIT = 20
const RECENT_TRANSCRIPT_TEXT_LIMIT = 12000
const ATTACHMENT_MIME_TYPES = new Set(['image/png', 'image/jpeg'])
const USER_ATTACHMENT_MIME_TYPES = new Set(['image/png', 'image/jpeg'])
const ATTACHMENT_MIME_TYPES = new Set([
...USER_ATTACHMENT_MIME_TYPES,
'audio/mpeg', 'audio/wav', 'audio/mp4',
'video/mp4', 'video/quicktime', 'video/webm',
])
const ATTACHMENT_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/
const RUN_STATUSES = new Set([
'completed', 'partial', 'failed', 'stopped', 'timeout', 'round-limit',
])
const PROGRESS_TITLES = new Set(['process', 'write_file'])
const PROGRESS_TITLES = new Set([
'reasoning', 'process', 'read_file', 'write_file', 'search',
'image_generation', 'audio_generation', 'video_generation', 'tool',
])
function emptyState() {
return {
@@ -88,9 +96,11 @@ function cleanRunMaxRounds(value) {
return Math.max(1, Math.min(MAX_AUTO_ROUNDS, Math.floor(requested)))
}
function cleanCurrentRound(value, maxRounds) {
function cleanCurrentRound(value, maxRounds, unlimitedRounds = false) {
const requested = Number(value)
if (!Number.isFinite(requested) || requested <= 0 || !maxRounds) return 0
if (!Number.isFinite(requested) || requested <= 0) return 0
if (unlimitedRounds) return Math.floor(requested)
if (!maxRounds) return 0
return Math.max(1, Math.min(maxRounds, Math.floor(requested)))
}
@@ -142,6 +152,12 @@ function normalizeSkillHint(input) {
return { targetKind, namespace, slug, name }
}
function normalizeTargetKinds(input) {
return [...new Set((Array.isArray(input) ? input : [])
.map(kind => cleanInline(kind, 40))
.filter(kind => Object.hasOwn(AGENT_LABELS, kind)))]
}
function skillHintsPrompt(hints) {
if (!hints.length) return ''
return [
@@ -209,20 +225,27 @@ function normalizeLoadedMessage(input) {
if (role === 'agent') {
const elapsedMs = cleanElapsedMs(input.elapsedMs)
const toolCalls = cleanProgressSteps(input.toolCalls)
const attachments = (Array.isArray(input.attachments) ? input.attachments : [])
.slice(0, MAX_MESSAGE_ATTACHMENTS)
.map(normalizeAttachmentMetadata)
.filter(Boolean)
if (elapsedMs != null) message.elapsedMs = elapsedMs
if (toolCalls.length) message.toolCalls = toolCalls
if (attachments.length) message.attachments = attachments
}
if (role === 'user') {
const attachments = (Array.isArray(input.attachments) ? input.attachments : [])
.slice(0, MAX_MESSAGE_ATTACHMENTS)
.map(normalizeAttachmentMetadata)
.filter(Boolean)
.filter(attachment => attachment && USER_ATTACHMENT_MIME_TYPES.has(attachment.mimeType))
const skillHints = (Array.isArray(input.skillHints) ? input.skillHints : [])
.slice(0, MAX_SKILL_HINTS)
.map(normalizeSkillHint)
.filter(Boolean)
const targetKinds = normalizeTargetKinds(input.targetKinds)
if (attachments.length) message.attachments = attachments
if (skillHints.length) message.skillHints = skillHints
if (targetKinds.length) message.targetKinds = targetKinds
}
return message
}
@@ -237,6 +260,8 @@ class LocalWorkspace extends EventEmitter {
if (attachments?.length) throw new Error('LOCAL_ATTACHMENT_STORAGE_UNAVAILABLE')
return []
})
this.captureAgentOutputsFn = options.captureAgentOutputs || (async () => null)
this.importAgentOutputsFn = options.importAgentOutputs || (async () => [])
this.validateSkillSelectionsFn = options.validateSkillSelections || ((_kind, selections) => selections)
this.imageAttachmentLimitFn = options.imageAttachmentLimit || (() => 0)
this.credentialStateFn = options.credentialState || (async () => ({ state: 'unknown', source: 'unverified' }))
@@ -306,7 +331,8 @@ class LocalWorkspace extends EventEmitter {
runningGroupIds: runEntries.map(([groupId]) => groupId),
runs: runEntries.map(([groupId, run, phase]) => {
const mode = run.mode === 'auto' ? 'auto' : 'manual'
const maxRounds = mode === 'auto' ? cleanRunMaxRounds(run.maxRounds) : 0
const unlimitedRounds = mode === 'auto' && run.unlimitedRounds === true
const maxRounds = mode === 'auto' && !unlimitedRounds ? cleanRunMaxRounds(run.maxRounds) : 0
return {
groupId,
phase,
@@ -315,8 +341,9 @@ class LocalWorkspace extends EventEmitter {
completedKinds: run.completedKinds || [],
failedKinds: run.failedKinds || [],
currentKind: run.currentKind || '',
currentRound: cleanCurrentRound(run.currentRound, maxRounds),
currentRound: cleanCurrentRound(run.currentRound, maxRounds, unlimitedRounds),
maxRounds,
unlimitedRounds,
progress: cleanProgressSteps(run.progress),
threadRootId: run.threadRootId || '',
startedAt: run.startedAt || Date.now(),
@@ -329,7 +356,7 @@ class LocalWorkspace extends EventEmitter {
this.emit('changed', this.snapshot())
}
createRunController(mode, targetKinds, threadRootId, maxRounds = 0) {
createRunController(mode, targetKinds, threadRootId, maxRounds = 0, unlimitedRounds = false) {
const controller = new AbortController()
let done = false
let resolveDone
@@ -347,7 +374,10 @@ class LocalWorkspace extends EventEmitter {
controller.progress = []
controller.threadRootId = threadRootId
controller.currentRound = 0
controller.maxRounds = mode === 'auto' ? cleanRunMaxRounds(maxRounds) : 0
controller.unlimitedRounds = mode === 'auto' && unlimitedRounds === true
controller.maxRounds = mode === 'auto' && !controller.unlimitedRounds
? cleanRunMaxRounds(maxRounds)
: 0
controller.startedAt = Date.now()
controller.stopReason = ''
return controller
@@ -357,10 +387,14 @@ class LocalWorkspace extends EventEmitter {
return this.preparingRuns.has(groupId) || this.activeRuns.has(groupId)
}
reserveRun(groupId, mode, targetKinds, threadRootId = '', maxRounds = 0) {
reserveRun(
groupId, mode, targetKinds, threadRootId = '', maxRounds = 0, unlimitedRounds = false,
) {
if (this.shuttingDown) throw new Error('LOCAL_AGENT_EXECUTION_STOPPED')
if (this.isGroupBusy(groupId)) throw new Error('LOCAL_GROUP_RUNNING')
const controller = this.createRunController(mode, targetKinds, threadRootId, maxRounds)
const controller = this.createRunController(
mode, targetKinds, threadRootId, maxRounds, unlimitedRounds,
)
this.preparingRuns.set(groupId, controller)
try {
this.emitChanged()
@@ -384,7 +418,10 @@ class LocalWorkspace extends EventEmitter {
return true
}
beginRun(groupId, mode, targetKinds, threadRootId, reservation = null, maxRounds = 0) {
beginRun(
groupId, mode, targetKinds, threadRootId, reservation = null, maxRounds = 0,
unlimitedRounds = false,
) {
if (this.shuttingDown) throw new Error('LOCAL_AGENT_EXECUTION_STOPPED')
let controller = reservation
if (controller) {
@@ -395,7 +432,9 @@ class LocalWorkspace extends EventEmitter {
this.preparingRuns.delete(groupId)
} else {
if (this.isGroupBusy(groupId)) throw new Error('LOCAL_GROUP_RUNNING')
controller = this.createRunController(mode, targetKinds, threadRootId, maxRounds)
controller = this.createRunController(
mode, targetKinds, threadRootId, maxRounds, unlimitedRounds,
)
}
controller.mode = mode
controller.targetKinds = [...targetKinds]
@@ -405,7 +444,9 @@ class LocalWorkspace extends EventEmitter {
controller.progress = []
controller.threadRootId = threadRootId
controller.currentRound = 0
controller.maxRounds = mode === 'auto'
controller.unlimitedRounds = mode === 'auto'
&& (unlimitedRounds === true || controller.unlimitedRounds === true)
controller.maxRounds = mode === 'auto' && !controller.unlimitedRounds
? cleanRunMaxRounds(maxRounds || controller.maxRounds)
: 0
controller.startedAt = Date.now()
@@ -659,8 +700,13 @@ class LocalWorkspace extends EventEmitter {
if (role === 'agent') {
const elapsedMs = cleanElapsedMs(metadata.elapsedMs)
const toolCalls = cleanProgressSteps(metadata.toolCalls)
const attachments = (Array.isArray(metadata.attachments) ? metadata.attachments : [])
.slice(0, MAX_MESSAGE_ATTACHMENTS)
.map(normalizeAttachmentMetadata)
.filter(Boolean)
if (elapsedMs != null) message.elapsedMs = elapsedMs
if (toolCalls.length) message.toolCalls = toolCalls
if (attachments.length) message.attachments = attachments
}
if (role === 'user') {
const attachments = (Array.isArray(metadata.attachments) ? metadata.attachments : [])
@@ -669,8 +715,10 @@ class LocalWorkspace extends EventEmitter {
const skillHints = (Array.isArray(metadata.skillHints) ? metadata.skillHints : [])
.map(normalizeSkillHint)
.filter(Boolean)
const targetKinds = normalizeTargetKinds(metadata.targetKinds)
if (attachments.length) message.attachments = attachments
if (skillHints.length) message.skillHints = skillHints
if (targetKinds.length) message.targetKinds = targetKinds
}
this.state.messages.push(message)
const group = this.getGroup(groupId)
@@ -763,7 +811,7 @@ class LocalWorkspace extends EventEmitter {
promptMessageText(message, limit = 20000) {
const attachmentNote = message.attachments?.length
? `[Attached images: ${message.attachments.map(item => item.name).join(', ')}]`
? `[Attached files: ${message.attachments.map(item => item.name).join(', ')}]`
: ''
return cleanText([message.content, attachmentNote].filter(Boolean).join(' '), limit)
}
@@ -825,10 +873,21 @@ class LocalWorkspace extends EventEmitter {
'Use agree only when you fully accept the current shared conclusion and add no new proposal, condition, or reservation. Otherwise use continue.',
].join('\n')
: 'Respond directly to the user and account for the other participants\' views. Do not speak for other Agents.'
const mediaDelivery = group.allowWrite
? [
'Media delivery contract: When the user asks for an image, audio, or video, do not claim it was generated unless a real file exists.',
'Save or copy each final media file into .meldwork-output/ in the conversation working directory. Supported extensions: .png, .jpg, .jpeg, .mp3, .wav, .m4a, .mp4, .mov, .webm.',
'Mention a delivered media file only after it has been written there.',
].join('\n')
: [
'This conversation is read-only. Do not claim that a media file was generated or delivered because no writable output can be attached.',
'If the user requests generated media, explain that workspace write access must be enabled before a real file can be delivered.',
].join('\n')
return [
`You are participating in the local "${group.name || 'Meldwork group'}" conversation as ${label}. Reply in the language used by the user unless they request another language.`,
`Group topic: ${cleanText(group.topic, 200) || '(not specified)'}`,
instruction,
mediaDelivery,
'Stable user instructions and constraints:',
this.stableUserInstructions(group.id, threadRootId) || '(none)',
'Recent conversation across the group:',
@@ -850,9 +909,22 @@ class LocalWorkspace extends EventEmitter {
const activeRun = this.activeRuns.get(group.id)
const onProgress = (step) => {
if (!activeRun || activeRun.currentKind !== kind) return
activeRun.progress = cleanProgressSteps([...(activeRun.progress || []), step])
const next = [...(activeRun.progress || [])]
const progressId = typeof step?.id === 'string' && /^[A-Za-z0-9._:-]{1,100}$/.test(step.id)
? step.id
: ''
const existingIndex = progressId
? next.findIndex(item => item?.id === progressId)
: -1
if (existingIndex >= 0) next[existingIndex] = { ...step, id: progressId }
else next.push(progressId ? { ...step, id: progressId } : step)
activeRun.progress = next.slice(-8)
this.emitChanged()
}
let outputBaseline = null
if (group.allowWrite) {
try { outputBaseline = await this.captureAgentOutputsFn(group.workdir) } catch { /* best effort */ }
}
let result
try {
result = await this.runAgentFn(
@@ -889,6 +961,21 @@ class LocalWorkspace extends EventEmitter {
status: step.status === 'in_progress' ? 'completed' : step.status,
}))
if (activeRun) activeRun.progress = toolCalls
let attachments = []
if (group.allowWrite) {
try {
const imported = await this.importAgentOutputsFn({
workdir: group.workdir,
baseline: outputBaseline,
startedAt,
agentKind: kind,
})
attachments = (Array.isArray(imported) ? imported : [])
.slice(0, MAX_MESSAGE_ATTACHMENTS)
.map(normalizeAttachmentMetadata)
.filter(Boolean)
} catch { /* the reply remains available when no valid media was produced */ }
}
const message = this.addMessage(
group.id,
'agent',
@@ -896,7 +983,7 @@ class LocalWorkspace extends EventEmitter {
kind,
threadRootId,
null,
{ elapsedMs: Date.now() - startedAt, toolCalls },
{ elapsedMs: Date.now() - startedAt, toolCalls, attachments },
)
return { message, consensus: reply.consensus && result.completed !== false }
}
@@ -917,6 +1004,9 @@ class LocalWorkspace extends EventEmitter {
if (!metadata || typeof attachment.path !== 'string' || !path.isAbsolute(attachment.path)) {
throw new Error('LOCAL_ATTACHMENT_REFERENCE_INVALID')
}
if (!USER_ATTACHMENT_MIME_TYPES.has(metadata.mimeType)) {
throw new Error('LOCAL_ATTACHMENT_REFERENCE_INVALID')
}
return { ...metadata, path: path.normalize(attachment.path) }
})
}
@@ -955,9 +1045,12 @@ class LocalWorkspace extends EventEmitter {
}
}
startAutoRunner(group, threadRootId, maxRounds, reservation = null, preparedContext = null) {
startAutoRunner(
group, threadRootId, maxRounds, reservation = null, preparedContext = null,
unlimitedRounds = false,
) {
const controller = this.beginRun(
group.id, 'auto', group.agentKinds, threadRootId, reservation, maxRounds,
group.id, 'auto', group.agentKinds, threadRootId, reservation, maxRounds, unlimitedRounds,
)
const timeout = setTimeout(() => {
if (controller.signal.aborted) return
@@ -998,7 +1091,11 @@ class LocalWorkspace extends EventEmitter {
const attachmentRecipients = new Set()
let consensusReached = false
const reportedFailures = new Set()
for (let round = 0; round < maxRounds && !controller.signal.aborted; round += 1) {
for (
let round = 0;
(controller.unlimitedRounds || round < maxRounds) && !controller.signal.aborted;
round += 1
) {
let agreements = 0
let successes = 0
controller.currentRound = round + 1
@@ -1056,7 +1153,7 @@ class LocalWorkspace extends EventEmitter {
threadRootId,
{ key: 'system.autoTimeout', params: {} },
)
} else if (runStatus === 'round-limit' || runStatus === 'failed') {
} else if (!controller.unlimitedRounds && (runStatus === 'round-limit' || runStatus === 'failed')) {
this.addMessage(
group.id,
'system',
@@ -1113,6 +1210,13 @@ class LocalWorkspace extends EventEmitter {
: (input.targetKinds?.length ? input.targetKinds : group.agentKinds)
const targetKinds = [...new Set(requested.filter(kind => group.agentKinds.includes(kind)))]
if (!targetKinds.length) throw new Error('LOCAL_MESSAGE_TARGET_REQUIRED')
if (input.mentionedAgentKinds != null && !Array.isArray(input.mentionedAgentKinds)) {
throw new Error('LOCAL_MESSAGE_TARGET_REQUIRED')
}
const mentionedAgentKinds = normalizeTargetKinds(input.mentionedAgentKinds)
if (mentionedAgentKinds.some(kind => !targetKinds.includes(kind))) {
throw new Error('LOCAL_MESSAGE_TARGET_REQUIRED')
}
if (mode === 'auto' && targetKinds.some(kind => (
!this.detectedAgents.some(agent => agent.kind === kind && agent.available)
))) {
@@ -1126,11 +1230,14 @@ class LocalWorkspace extends EventEmitter {
if (requestedSkillHints.some(skill => !targetKinds.includes(String(skill?.targetKind || '')))) {
throw new Error('LOCAL_SKILL_SELECTION_INVALID')
}
const maxRounds = mode === 'auto'
const unlimitedRounds = mode === 'auto' && input.unlimitedRounds === true
const maxRounds = mode === 'auto' && !unlimitedRounds
? normalizeAutoRounds(input.maxRounds ?? input.maxTurns)
: 0
const requestedThreadRootId = mode === 'manual' ? cleanText(input.threadRootId, 100) : ''
const reservation = this.reserveRun(group.id, mode, targetKinds, '', maxRounds)
const reservation = this.reserveRun(
group.id, mode, targetKinds, '', maxRounds, unlimitedRounds,
)
const promise = (async () => {
let controller = null
let autoStarted = false
@@ -1150,11 +1257,12 @@ class LocalWorkspace extends EventEmitter {
{
attachments: prepared.attachments.map(({ path: _path, ...metadata }) => metadata),
skillHints: prepared.skillHints,
targetKinds: mentionedAgentKinds,
},
)
try {
controller = this.startAutoRunner(
group, userMessage.id, maxRounds, reservation, prepared,
group, userMessage.id, maxRounds, reservation, prepared, unlimitedRounds,
)
} catch (error) {
const messageIndex = this.state.messages.findIndex(message => message === userMessage)
@@ -1167,7 +1275,12 @@ class LocalWorkspace extends EventEmitter {
throw error
}
autoStarted = true
return { started: true, maxRounds, threadRootId: userMessage.id }
return {
started: true,
maxRounds,
threadRootId: userMessage.id,
...(unlimitedRounds ? { unlimitedRounds: true } : {}),
}
}
controller = this.beginRun(group.id, 'manual', targetKinds, '', reservation)
@@ -1182,6 +1295,7 @@ class LocalWorkspace extends EventEmitter {
{
attachments: prepared.attachments.map(({ path: _path, ...metadata }) => metadata),
skillHints: prepared.skillHints,
targetKinds: mentionedAgentKinds,
},
)
const threadRootId = group.conversationType === 'direct' ? '' : userMessage.id
@@ -1237,7 +1351,10 @@ class LocalWorkspace extends EventEmitter {
const group = this.getGroup(input.groupId)
if (this.isGroupBusy(group.id)) throw new Error('LOCAL_GROUP_RUNNING')
if (group.agentKinds.length < 2) throw new Error('LOCAL_AUTO_AGENT_COUNT')
const maxRounds = normalizeAutoRounds(input.maxRounds ?? input.maxTurns)
const unlimitedRounds = input.unlimitedRounds === true
const maxRounds = unlimitedRounds
? 0
: normalizeAutoRounds(input.maxRounds ?? input.maxTurns)
const latestRoot = this.state.messages.findLast(message => (
message.groupId === group.id && message.role === 'user' && !message.threadRootId
))
@@ -1254,8 +1371,8 @@ class LocalWorkspace extends EventEmitter {
if (rootAttachmentCount && !limit) throw new Error('LOCAL_AGENT_IMAGE_UNSUPPORTED')
if (rootAttachmentCount > limit) throw new Error('LOCAL_AGENT_IMAGE_LIMIT')
}
this.startAutoRunner(group, threadRootId, maxRounds)
return { started: true, maxRounds }
this.startAutoRunner(group, threadRootId, maxRounds, null, null, unlimitedRounds)
return { started: true, maxRounds, ...(unlimitedRounds ? { unlimitedRounds: true } : {}) }
}
stop(groupId) {
+108 -16
View File
@@ -1,9 +1,13 @@
const electron = require('electron')
const { app, BrowserWindow, dialog, ipcMain, Menu, Notification, shell } = electron
const { app, BrowserWindow, dialog, ipcMain, Menu, Notification, protocol, shell } = electron
const fs = require('node:fs')
const path = require('node:path')
const { fileURLToPath } = require('node:url')
const { AttachmentStore } = require('./attachment-store.cjs')
const {
captureAgentOutputState,
importAgentOutputs,
} = require('./agent-output-importer.cjs')
const {
assertImagePixelLimit,
inspectImageDimensions,
@@ -19,7 +23,8 @@ const { LocalSkillCatalog } = require('./local-skill-catalog.cjs')
const { managedOpenClawOptions } = require('./openclaw-runtime.cjs')
const { ProviderStore } = require('./provider-store.cjs')
const SHARED_PROVIDER_KINDS = new Set(['hermes', 'openclaw', 'workbuddy', 'qwen'])
const EXTERNAL_PROVIDER_KINDS = new Set(['hermes', 'openclaw', 'workbuddy', 'qwen'])
const MEDIA_SCHEME = 'meldwork-media'
const ATTACHMENT_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/
const MAX_ATTACHMENT_PICK_REQUEST = 4
const MAX_ATTACHMENT_DISCARD_REQUEST = 4
@@ -44,6 +49,16 @@ let quitAfterCleanup = false
let quitCleanup = null
let pendingOpenGroupId = ''
protocol?.registerSchemesAsPrivileged?.([{
scheme: MEDIA_SCHEME,
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
stream: true,
},
}])
const lazySafeStorage = Object.freeze({
isEncryptionAvailable: () => electron.safeStorage.isEncryptionAvailable(),
encryptString: value => electron.safeStorage.encryptString(value),
@@ -175,18 +190,24 @@ function providerOptionsFor(kind, generic, context = {}) {
}
function providerOptions(kind, context = {}) {
if (!SHARED_PROVIDER_KINDS.has(kind)) return {}
if (!providerStore?.status().configured) {
if (!EXTERNAL_PROVIDER_KINDS.has(kind)) return {}
if (!providerStore?.status(kind).configured) {
if (kind === 'openclaw') throw new Error('PROVIDER_CREDENTIAL_REQUIRED')
return {}
}
return providerOptionsFor(
kind,
providerStore.envForAgent(),
providerStore.envForAgent(kind),
context,
)
}
function providerAgentKind(value) {
const kind = String(value || '').trim()
if (!EXTERNAL_PROVIDER_KINDS.has(kind)) throw new Error('PROVIDER_AGENT_UNSUPPORTED')
return kind
}
function workspaceStoragePath(userData = app.getPath('userData')) {
return path.join(userData, 'roundrelay-workspace.json')
}
@@ -272,6 +293,73 @@ function attachmentPreview(id) {
}
}
function mediaRequestId(value) {
try {
const target = new URL(value)
const id = decodeURIComponent(target.pathname.replace(/^\//, ''))
if (target.protocol !== `${MEDIA_SCHEME}:` || target.hostname !== 'attachment'
|| target.username || target.password || target.port || target.search || target.hash
|| !ATTACHMENT_ID.test(id)) {
return ''
}
return id
} catch {
return ''
}
}
function mediaByteRange(value, size) {
if (!value) return null
const match = /^bytes=(\d*)-(\d*)$/.exec(String(value).trim())
if (!match || (!match[1] && !match[2])) return false
let start
let end
if (!match[1]) {
const suffix = Number(match[2])
if (!Number.isSafeInteger(suffix) || suffix <= 0) return false
start = Math.max(0, size - suffix)
end = size - 1
} else {
start = Number(match[1])
end = match[2] ? Number(match[2]) : size - 1
}
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end)
|| start < 0 || start >= size || end < start) return false
return { start, end: Math.min(end, size - 1) }
}
function attachmentMediaResponse(request) {
const id = mediaRequestId(request?.url)
if (!id) return new Response(null, { status: 404 })
let entry
try { entry = availableAttachmentStore().readWithMetadata(id) } catch { return new Response(null, { status: 404 }) }
const { metadata, bytes } = entry
if (!/^(?:image|audio|video)\//.test(metadata.mimeType) || bytes.length !== metadata.size) {
return new Response(null, { status: 415 })
}
const range = mediaByteRange(request?.headers?.get?.('range'), bytes.length)
if (range === false) {
return new Response(null, {
status: 416,
headers: { 'Content-Range': `bytes */${bytes.length}` },
})
}
const body = range ? bytes.subarray(range.start, range.end + 1) : bytes
const headers = {
'Accept-Ranges': 'bytes',
'Cache-Control': 'private, max-age=31536000, immutable',
'Content-Length': String(body.length),
'Content-Type': metadata.mimeType,
'X-Content-Type-Options': 'nosniff',
}
if (range) headers['Content-Range'] = `bytes ${range.start}-${range.end}/${bytes.length}`
return new Response(body, { status: range ? 206 : 200, headers })
}
function registerMediaProtocol() {
protocol?.handle?.(MEDIA_SCHEME, request => attachmentMediaResponse(request))
}
function importAttachmentBuffer(input) {
const store = availableAttachmentStore()
const metadata = store.importBuffer(input)
@@ -387,15 +475,17 @@ function createWorkspace() {
storagePath: workspaceStoragePath(),
detectAgents: () => installer.detectedAgents(),
resolveAttachments: attachments => availableAttachmentStore().resolve(attachments),
captureAgentOutputs: workdir => captureAgentOutputState(workdir),
importAgentOutputs: input => importAgentOutputs(input, availableAttachmentStore()),
validateSkillSelections: (kind, selections) => skillCatalog.validateSelections(kind, selections),
imageAttachmentLimit,
credentialState: (kind, agent) => {
if (kind === 'openclaw' && !providerStore?.status().configured) {
return { state: 'missing', source: 'shared-provider-required' }
if (kind === 'openclaw' && !providerStore?.status(kind).configured) {
return { state: 'missing', source: 'agent-provider-required' }
}
return resolveNativeCredentialState(kind, { executable: agent?.executable })
},
sharedProviderReady: kind => SHARED_PROVIDER_KINDS.has(kind) && providerStore.status().configured,
sharedProviderReady: kind => EXTERNAL_PROVIDER_KINDS.has(kind) && providerStore.status(kind).configured,
runAgent: async (agent, prompt, workdir, options = {}) => {
const injected = providerOptions(agent.kind, {
...options,
@@ -599,14 +689,14 @@ function registerIpc() {
registerTrustedHandle('local-attachments:discard', (ids) => {
return discardUnreferencedAttachments(ids)
})
registerTrustedHandle('local-agent-provider:status', () => {
return providerStore.status()
registerTrustedHandle('local-agent-provider:status', (kind) => {
return providerStore.status(providerAgentKind(kind))
})
registerTrustedHandle('local-agent-provider:probe', () => {
return providerStore.status({ probeEncryption: true })
registerTrustedHandle('local-agent-provider:probe', (kind) => {
return providerStore.status(providerAgentKind(kind), { probeEncryption: true })
})
registerTrustedHandle('local-agent-provider:save', async (input) => {
const result = providerStore.save({
registerTrustedHandle('local-agent-provider:save', async (kind, input) => {
const result = providerStore.save(providerAgentKind(kind), {
apiKey: input?.apiKey,
provider: input?.provider,
baseUrl: input?.baseUrl,
@@ -615,8 +705,8 @@ function registerIpc() {
await refreshLocalAgentState()
return result
})
registerTrustedHandle('local-agent-provider:delete', async () => {
const result = providerStore.delete()
registerTrustedHandle('local-agent-provider:delete', async (kind) => {
const result = providerStore.delete(providerAgentKind(kind))
await refreshLocalAgentState()
return result
})
@@ -634,6 +724,7 @@ if (!hasSingleInstanceLock) {
providerStore = new ProviderStore({
storagePath: path.join(app.getPath('userData'), 'roundrelay-provider.json'),
safeStorage: lazySafeStorage,
allowedKinds: [...EXTERNAL_PROVIDER_KINDS],
})
try {
attachmentStore = new AttachmentStore({
@@ -642,6 +733,7 @@ if (!hasSingleInstanceLock) {
} catch {
attachmentStore = null
}
registerMediaProtocol()
skillCatalog = new LocalSkillCatalog({ home: app.getPath('home') })
installer = new AgentInstaller({
detectAgents,
+4 -4
View File
@@ -92,10 +92,10 @@ if (isLocalDocument) Object.assign(desktopApi, {
discard: ids => ipcRenderer.invoke('local-attachments:discard', ids),
}),
localAgentProvider: Object.freeze({
status: () => ipcRenderer.invoke('local-agent-provider:status'),
probe: () => ipcRenderer.invoke('local-agent-provider:probe'),
save: input => ipcRenderer.invoke('local-agent-provider:save', input),
delete: () => ipcRenderer.invoke('local-agent-provider:delete'),
status: kind => ipcRenderer.invoke('local-agent-provider:status', kind),
probe: kind => ipcRenderer.invoke('local-agent-provider:probe', kind),
save: (kind, input) => ipcRenderer.invoke('local-agent-provider:save', kind, input),
delete: kind => ipcRenderer.invoke('local-agent-provider:delete', kind),
}),
})
+87 -25
View File
@@ -2,7 +2,9 @@ const fs = require('node:fs')
const { atomicWritePrivateFile } = require('./private-file.cjs')
const STORE_VERSION = 1
const STORE_VERSION = 2
const LEGACY_STORE_VERSION = 1
const PROVIDER_KIND = /^[a-z0-9][a-z0-9-]{0,31}$/
const EMPTY_METADATA = Object.freeze({ provider: '', baseUrl: '', model: '' })
function normalizeMetadata(input) {
@@ -32,12 +34,17 @@ function sortedKeys(value) {
}
class ProviderStore {
constructor({ storagePath, safeStorage }) {
constructor({ storagePath, safeStorage, allowedKinds = [] }) {
if (typeof storagePath !== 'string' || !storagePath) {
throw new Error('PROVIDER_STORAGE_PATH_REQUIRED')
}
const normalizedKinds = [...new Set(allowedKinds.map(kind => String(kind || '').trim()))]
if (!normalizedKinds.length || normalizedKinds.some(kind => !PROVIDER_KIND.test(kind))) {
throw new Error('PROVIDER_AGENT_KINDS_REQUIRED')
}
this.storagePath = storagePath
this.safeStorage = safeStorage
this.allowedKinds = new Set(normalizedKinds)
}
encryptionAvailable() {
@@ -48,7 +55,8 @@ class ProviderStore {
}
}
status({ probeEncryption = false } = {}) {
status(kind, { probeEncryption = false } = {}) {
const targetKind = this.normalizeKind(kind)
const credentialStored = fs.existsSync(this.storagePath)
if (!credentialStored && !probeEncryption) {
return {
@@ -65,10 +73,12 @@ class ProviderStore {
let configured = false
if (encryptionAvailable && credentialStored) {
try {
const entry = this.readDocument()
this.decryptEntry(entry)
metadata = entry
configured = true
const entry = this.readDocument().agents[targetKind]
if (entry) {
this.decryptEntry(entry)
metadata = entry
configured = true
}
} catch { /* unreadable credentials stay unavailable */ }
}
return {
@@ -80,7 +90,8 @@ class ProviderStore {
}
}
save(input) {
save(kind, input) {
const targetKind = this.normalizeKind(kind)
if (sortedKeys(input) !== 'apiKey,baseUrl,model,provider'
|| typeof input.apiKey !== 'string' || !input.apiKey.trim()
|| input.apiKey.trim().length > 8192) {
@@ -101,24 +112,36 @@ class ProviderStore {
throw new Error('PROVIDER_ENCRYPTION_FAILED')
}
atomicWritePrivateFile(this.storagePath, JSON.stringify({
version: STORE_VERSION,
const document = fs.existsSync(this.storagePath)
? this.readDocument()
: { version: STORE_VERSION, agents: {} }
document.agents[targetKind] = {
...metadata,
encrypted: encrypted.toString('base64'),
}))
return this.status()
}
delete() {
try { fs.unlinkSync(this.storagePath) } catch (error) {
if (error.code !== 'ENOENT') throw error
}
return this.status({ probeEncryption: true })
atomicWritePrivateFile(this.storagePath, JSON.stringify(document))
return this.status(targetKind)
}
envForAgent() {
delete(kind) {
const targetKind = this.normalizeKind(kind)
if (fs.existsSync(this.storagePath)) {
const document = this.readDocument()
delete document.agents[targetKind]
if (Object.keys(document.agents).length) {
atomicWritePrivateFile(this.storagePath, JSON.stringify(document))
} else {
fs.unlinkSync(this.storagePath)
}
}
return this.status(targetKind, { probeEncryption: true })
}
envForAgent(kind) {
const targetKind = this.normalizeKind(kind)
this.requireEncryption()
const entry = this.readDocument()
const entry = this.readDocument().agents[targetKind]
if (!entry) throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE')
return {
OPENAI_API_KEY: this.decryptEntry(entry),
OPENAI_BASE_URL: entry.baseUrl,
@@ -130,6 +153,14 @@ class ProviderStore {
if (!this.encryptionAvailable()) throw new Error('PROVIDER_ENCRYPTION_UNAVAILABLE')
}
normalizeKind(kind) {
const normalized = String(kind || '').trim()
if (!PROVIDER_KIND.test(normalized) || !this.allowedKinds.has(normalized)) {
throw new Error('PROVIDER_AGENT_UNSUPPORTED')
}
return normalized
}
decryptEntry(entry) {
this.requireEncryption()
try {
@@ -152,15 +183,46 @@ class ProviderStore {
} catch {
throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE')
}
if (sortedKeys(payload) !== 'baseUrl,encrypted,model,provider,version'
|| payload.version !== STORE_VERSION || typeof payload.encrypted !== 'string') {
if (payload?.version === LEGACY_STORE_VERSION) {
if (sortedKeys(payload) !== 'baseUrl,encrypted,model,provider,version'
|| typeof payload.encrypted !== 'string') {
throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE')
}
const entry = this.normalizeEntry({
provider: payload.provider,
baseUrl: payload.baseUrl,
model: payload.model,
encrypted: payload.encrypted,
})
return {
version: STORE_VERSION,
agents: Object.fromEntries([...this.allowedKinds].map(kind => [kind, { ...entry }])),
}
}
if (sortedKeys(payload) !== 'agents,version'
|| payload.version !== STORE_VERSION
|| !payload.agents || typeof payload.agents !== 'object'
|| Array.isArray(payload.agents)) {
throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE')
}
const encrypted = Buffer.from(payload.encrypted, 'base64')
if (!encrypted.length || encrypted.toString('base64') !== payload.encrypted) {
const agents = {}
for (const [kind, entry] of Object.entries(payload.agents)) {
if (!this.allowedKinds.has(kind)) throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE')
agents[kind] = this.normalizeEntry(entry)
}
return { version: STORE_VERSION, agents }
}
normalizeEntry(entry) {
if (sortedKeys(entry) !== 'baseUrl,encrypted,model,provider'
|| typeof entry.encrypted !== 'string') {
throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE')
}
return { ...normalizeMetadata(payload), encrypted: payload.encrypted }
const encrypted = Buffer.from(entry.encrypted, 'base64')
if (!encrypted.length || encrypted.toString('base64') !== entry.encrypted) {
throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE')
}
return { ...normalizeMetadata(entry), encrypted: entry.encrypted }
}
}
@@ -0,0 +1,84 @@
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 { AttachmentStore } = require('../src/attachment-store.cjs')
const {
captureAgentOutputState,
importAgentOutputs,
} = require('../src/agent-output-importer.cjs')
const PNG = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
0x00, 0x00, 0x00, 0x0d,
])
const MP3 = Buffer.from('49443304000000000000', 'hex')
const MP4 = Buffer.concat([Buffer.alloc(4), Buffer.from('ftypisom'), Buffer.alloc(8)])
function fixture(t) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'roundrelay-agent-output-'))
const workdir = path.join(directory, 'workspace')
const output = path.join(workdir, '.meldwork-output')
fs.mkdirSync(output, { recursive: true })
let sequence = 0
const store = new AttachmentStore({
rootPath: path.join(directory, 'attachments'),
createId: () => `generated-${++sequence}`,
})
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
return { directory, workdir, output, store }
}
test('imports only new or changed top-level media from the controlled output directory', (t) => {
const { directory, workdir, output, store } = fixture(t)
fs.writeFileSync(path.join(output, 'old.png'), PNG)
fs.writeFileSync(path.join(output, 'ignore.txt'), 'not media')
const baseline = captureAgentOutputState(workdir)
const startedAt = Date.now()
fs.writeFileSync(path.join(output, 'poster.png'), PNG)
fs.writeFileSync(path.join(output, 'voice.mp3'), MP3)
fs.writeFileSync(path.join(output, 'demo.mp4'), MP4)
fs.writeFileSync(path.join(output, 'renamed.jpg'), PNG)
fs.mkdirSync(path.join(output, 'nested'))
fs.writeFileSync(path.join(output, 'nested', 'hidden.png'), PNG)
if (process.platform !== 'win32') {
const outside = path.join(directory, 'outside.png')
fs.writeFileSync(outside, PNG)
fs.symlinkSync(outside, path.join(output, 'linked.png'))
}
const imported = importAgentOutputs({ workdir, baseline, startedAt }, store)
assert.deepEqual(imported.map(item => item.name).sort(), [
'demo.mp4', 'poster.png', 'voice.mp3',
])
assert.equal(imported.length, 3)
assert.equal(imported.some(item => item.name === 'old.png'), false)
assert.equal(imported.some(item => item.name === 'hidden.png'), false)
assert.equal(imported.some(item => item.name === 'linked.png'), false)
})
test('requires a matching pre-run baseline and refuses symlinked output directories', {
skip: process.platform === 'win32',
}, (t) => {
const { directory, workdir, output, store } = fixture(t)
fs.writeFileSync(path.join(output, 'poster.png'), PNG)
assert.deepEqual(importAgentOutputs({ workdir, baseline: null, startedAt: Date.now() }, store), [])
fs.rmSync(output, { recursive: true })
const outside = path.join(directory, 'outside-output')
fs.mkdirSync(outside)
fs.writeFileSync(path.join(outside, 'outside.png'), PNG)
fs.symlinkSync(outside, output)
assert.deepEqual(captureAgentOutputState(workdir).files, {})
assert.deepEqual(importAgentOutputs({
workdir,
baseline: { workdirRealPath: fs.realpathSync(workdir), files: {} },
startedAt: Date.now(),
}, store), [])
})
+47
View File
@@ -16,6 +16,14 @@ const WEBP = Buffer.from([
0x52, 0x49, 0x46, 0x46, 0x04, 0x00, 0x00, 0x00,
0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x20,
])
const MP3 = Buffer.from('49443304000000000000', 'hex')
const WAV = Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WAVEfmt ')])
const M4A = Buffer.concat([Buffer.alloc(4), Buffer.from('ftypM4A '), Buffer.alloc(8)])
const MP4 = Buffer.concat([Buffer.alloc(4), Buffer.from('ftypisom'), Buffer.alloc(8)])
const MOV = Buffer.concat([Buffer.alloc(4), Buffer.from('ftypqt '), Buffer.alloc(8)])
const WEBM_VIDEO = Buffer.concat([
Buffer.from([0x1a, 0x45, 0xdf, 0xa3]), Buffer.alloc(8), Buffer.from('webm'),
])
function fixture(t, createId) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'roundrelay-attachments-'))
@@ -84,6 +92,45 @@ test('accepts PNG and JPEG using their real magic bytes', (t) => {
)
})
test('imports validated audio and video while preserving controlled storage paths', (t) => {
const { rootPath, store } = fixture(t)
const fixtures = [
[MP3, 'briefing.mp3', 'audio/mpeg', 'media.mp3'],
[WAV, 'voice.wav', 'audio/wav', 'media.wav'],
[M4A, 'meeting.m4a', 'audio/mp4', 'media.m4a'],
[MP4, 'demo.mp4', 'video/mp4', 'media.mp4'],
[MOV, 'walkthrough.mov', 'video/quicktime', 'media.mov'],
[WEBM_VIDEO, 'preview.webm', 'video/webm', 'media.webm'],
]
for (const [bytes, name, mimeType, storedName] of fixtures) {
const metadata = store.importBuffer({ bytes, name, mimeType })
assert.equal(metadata.name, name)
assert.equal(metadata.mimeType, mimeType)
const [resolved] = store.resolve([metadata.id])
assert.equal(path.basename(resolved.path), storedName)
assert.deepEqual(store.read(metadata.id), bytes)
assert.equal(path.dirname(path.dirname(resolved.path)), rootPath)
}
})
test('rejects media extension, MIME, and magic-byte mismatches', (t) => {
const { store } = fixture(t)
assert.throws(
() => store.importBuffer({ bytes: MP3, name: 'voice.wav', mimeType: 'audio/mpeg' }),
{ message: 'LOCAL_ATTACHMENT_TYPE_MISMATCH' },
)
assert.throws(
() => store.importBuffer({ bytes: MP4, name: 'demo.mp4', mimeType: 'video/quicktime' }),
{ message: 'LOCAL_ATTACHMENT_TYPE_MISMATCH' },
)
assert.throws(
() => store.importBuffer({ bytes: WEBP, name: 'preview.webm', mimeType: 'video/webm' }),
{ message: 'LOCAL_ATTACHMENT_TYPE_UNSUPPORTED' },
)
})
test('rejects unsupported bytes and declared type or extension mismatches', (t) => {
const { directory, store } = fixture(t)
+81
View File
@@ -177,6 +177,87 @@ test('Codex JSONL output returns the reply and session id', () => {
})
})
test('runAgent streams bounded Codex progress before the final reply without exposing commands', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'roundrelay-codex-progress-'))
const cli = executable(directory, 'codex-progress.cjs', `
const events = [
{ type: 'thread.started', thread_id: 'thread-progress' },
{ type: 'turn.started' },
{
type: 'item.started',
item: {
id: 'item-search', type: 'command_execution',
command: 'rg -n secret /Users/private/workspace', status: 'in_progress',
},
},
{
type: 'item.completed',
item: {
id: 'item-search', type: 'command_execution',
command: 'rg -n secret /Users/private/workspace', exit_code: 0, status: 'completed',
},
},
{
type: 'item.started',
item: { id: 'item-image', type: 'image_generation', status: 'in_progress' },
},
{
type: 'item.completed',
item: { id: 'item-image', type: 'image_generation', status: 'completed' },
},
{
type: 'item.completed',
item: { id: 'item-message', type: 'agent_message', text: 'final reply' },
},
{ type: 'turn.completed' },
]
let index = 0
function send() {
if (index >= events.length) return
const line = JSON.stringify(events[index++]) + '\\n'
const split = Math.max(1, Math.floor(line.length / 2))
process.stdout.write(line.slice(0, split))
setTimeout(() => {
process.stdout.write(line.slice(split))
if (index >= events.length) return setTimeout(() => process.exit(0), 25)
setTimeout(send, 25)
}, 10)
}
send()
`)
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const progress = []
let firstProgressResolve
const firstProgress = new Promise(resolve => { firstProgressResolve = resolve })
const resultPromise = runAgent(
{ kind: 'codex', executable: cli, name: 'Codex' },
'hello',
directory,
{
onProgress: step => {
progress.push(step)
firstProgressResolve()
},
},
)
await within(firstProgress)
assert.equal(progress.length > 0, true)
const result = await resultPromise
assert.equal(result.text, 'final reply')
assert.equal(result.sessionRef, 'thread-progress')
assert.deepEqual(result.progress, [
{ id: 'turn', title: 'process', status: 'completed' },
{ id: 'item-search', title: 'search', status: 'completed' },
{ id: 'item-image', title: 'image_generation', status: 'completed' },
])
assert.equal(progress.some(step => step.id === 'item-search' && step.status === 'in_progress'), true)
assert.equal(progress.some(step => step.id === 'item-search' && step.status === 'completed'), true)
assert.doesNotMatch(JSON.stringify(progress), /secret|Users|workspace|rg -n/)
})
test('Hermes uses quiet query mode and resumes the native session id without a shell', () => {
const spec = invocation('hermes', '/tmp/hermes', '/tmp', 'hermes-session-123')
assert.equal(spec.promptArg, true)
+168 -1
View File
@@ -30,6 +30,8 @@ function fixture() {
})),
validateSkillSelections: (_kind, selections) => selections,
imageAttachmentLimit: kind => ({ codex: 4, hermes: 1, opencode: 4 })[kind] || 0,
captureAgentOutputs: async () => null,
importAgentOutputs: async () => [],
runAgent: async (agent, prompt, workdir, runOptions) => {
calls.push({ agent, prompt, workdir, runOptions })
return {
@@ -246,11 +248,17 @@ test('groups and messages persist without exposing executable paths', async (t)
agentKinds: ['codex', 'hermes'],
workdir: directory,
})
await workspace.sendMessage({ groupId: group.id, text: '开始讨论', targetKinds: ['codex'] })
await workspace.sendMessage({
groupId: group.id,
text: '开始讨论',
targetKinds: ['codex'],
mentionedAgentKinds: ['codex'],
})
const restored = new LocalWorkspace(options)
assert.equal(restored.snapshot().groups[0].name, '本地测试群')
assert.equal(restored.snapshot().messages.length, 2)
assert.deepEqual(restored.snapshot().messages[0].targetKinds, ['codex'])
assert.equal('executable' in workspace.snapshot().agents[0], false)
})
@@ -302,6 +310,15 @@ test('Skills are validated and injected only into their selected target Agent',
}),
{ message: 'LOCAL_SKILL_SELECTION_INVALID' },
)
await assert.rejects(
workspace.sendMessage({
groupId: group.id,
text: 'Invalid mention',
targetKinds: ['codex'],
mentionedAgentKinds: ['hermes'],
}),
{ message: 'LOCAL_MESSAGE_TARGET_REQUIRED' },
)
assert.equal(workspace.snapshot().messages.filter(message => message.role === 'user').length, 1)
})
@@ -625,6 +642,7 @@ test('workspace loading allowlists local group and message fields', async (t) =>
name: 'Review',
path: '/private/SKILL.md',
}]
stored.messages[0].targetKinds = ['codex', 'unknown-agent', 'codex']
stored.messages[1].elapsedMs = 12.4
stored.messages[1].toolCalls = [
...Array.from({ length: 8 }, () => ({ title: 'write_file', status: 'completed' })),
@@ -647,6 +665,7 @@ test('workspace loading allowlists local group and message fields', async (t) =>
assert.deepEqual(restoredSnapshot.messages[0].skillHints, [{
targetKind: 'codex', namespace: 'global', slug: 'review', name: 'Review',
}])
assert.deepEqual(restoredSnapshot.messages[0].targetKinds, ['codex'])
assert.equal(restoredSnapshot.messages[1].elapsedMs, 12)
assert.equal(restoredSnapshot.messages[1].toolCalls.length, 8)
assert.deepEqual(restoredSnapshot.messages[1].toolCalls.at(-1), {
@@ -683,6 +702,71 @@ test('direct conversations force manual mode and reuse their group Agent session
assert.deepEqual(restored.snapshot().groups[0], direct)
})
test('writable conversations persist validated Agent media outputs and enforce the delivery contract', async (t) => {
const { directory, calls, options } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const captureCalls = []
const importCalls = []
const generated = [
{ id: 'generated-image', name: 'poster.png', mimeType: 'image/png', size: 128 },
{ id: 'generated-audio', name: 'briefing.mp3', mimeType: 'audio/mpeg', size: 256 },
{ id: 'generated-video', name: 'demo.mp4', mimeType: 'video/mp4', size: 512 },
]
options.captureAgentOutputs = async (workdir) => {
captureCalls.push(workdir)
return { marker: 'before-run' }
}
options.importAgentOutputs = async (input) => {
importCalls.push(input)
return generated
}
const workspace = new LocalWorkspace(options)
await workspace.refreshAgents()
const direct = workspace.createGroup({
name: 'Codex media', workdir: directory, allowWrite: true,
conversationType: 'direct', directAgentKind: 'codex', agentKinds: ['codex'],
})
await workspace.sendMessage({ groupId: direct.id, text: '生成一张图、一段音频和一个视频' })
assert.deepEqual(captureCalls, [directory])
assert.equal(importCalls.length, 1)
assert.equal(importCalls[0].workdir, directory)
assert.deepEqual(importCalls[0].baseline, { marker: 'before-run' })
assert.equal(Number.isFinite(importCalls[0].startedAt), true)
assert.match(calls[0].prompt, /\.meldwork-output\//)
assert.match(calls[0].prompt, /do not claim it was generated unless a real file exists/i)
const reply = workspace.snapshot().messages.find(message => message.role === 'agent')
assert.deepEqual(reply.attachments, generated)
assert.equal(JSON.stringify(reply).includes(directory), false)
const restored = new LocalWorkspace(options)
const restoredReply = restored.snapshot().messages.find(message => message.role === 'agent')
assert.deepEqual(restoredReply.attachments, generated)
})
test('read-only conversations forbid false media claims and do not scan for generated files', async (t) => {
const { directory, calls, options } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
let captureCount = 0
let importCount = 0
options.captureAgentOutputs = async () => { captureCount += 1; return {} }
options.importAgentOutputs = async () => { importCount += 1; return [] }
const workspace = new LocalWorkspace(options)
await workspace.refreshAgents()
const direct = workspace.createGroup({
name: 'Read only Codex', workdir: directory,
conversationType: 'direct', directAgentKind: 'codex', agentKinds: ['codex'],
})
await workspace.sendMessage({ groupId: direct.id, text: '生成图片' })
assert.equal(captureCount, 0)
assert.equal(importCount, 0)
assert.match(calls[0].prompt, /read-only/i)
assert.match(calls[0].prompt, /do not claim that a media file was generated/i)
})
test('Gemini and OpenCode messages keep their Agent names', async (t) => {
const { directory, options } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
@@ -1215,6 +1299,32 @@ test('Hermes progress stays bounded metadata while later Agents receive only its
assert.equal(typeof persistedReply.elapsedMs, 'number')
})
test('running progress updates an existing Codex event instead of appending duplicates', async (t) => {
const { directory, options } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const release = deferred()
options.runAgent = async (_agent, _prompt, _workdir, runOptions) => {
runOptions.onProgress({ id: 'item-1', title: 'search', status: 'in_progress' })
runOptions.onProgress({ id: 'item-1', title: 'search', status: 'completed' })
await release.promise
return { text: 'done', sessionRef: 'codex-session' }
}
const workspace = new LocalWorkspace(options)
await workspace.refreshAgents()
const group = workspace.createGroup({
name: 'Codex progress', agentKinds: ['codex'], workdir: directory,
})
const send = workspace.sendMessage({ groupId: group.id, text: 'Search' })
await new Promise(resolve => setImmediate(resolve))
assert.deepEqual(workspace.snapshot().runs[0].progress, [
{ title: 'search', status: 'completed' },
])
release.resolve()
await send
})
test('all failed agents resolve after persisting one user message and recording failures', async (t) => {
const { directory, calls, options } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
@@ -1299,6 +1409,7 @@ test('auto send preflights atomically, persists one root, and starts at round on
assert.deepEqual(started, { started: true, maxRounds: 2, threadRootId: root.id })
assert.equal(active.messages.filter(message => message.role === 'user').length, 1)
assert.equal(root.threadRootId, undefined)
assert.equal(root.targetKinds, undefined)
assert.deepEqual(active.runs.map(run => ({
mode: run.mode,
currentRound: run.currentRound,
@@ -1330,6 +1441,62 @@ test('auto send preflights atomically, persists one root, and starts at round on
assert.equal(finished[0].status, 'completed')
})
test('unlimited automatic discussion continues past a finite cap until consensus', async (t) => {
const { directory, calls, options } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const firstCallEntered = deferred()
const firstCallGate = deferred()
options.runAgent = async (agent, prompt, workdir, runOptions) => {
calls.push({ agent, prompt, workdir, runOptions })
if (calls.length === 1) {
firstCallEntered.resolve()
await firstCallGate.promise
}
const agreed = calls.length > 2
return {
text: `${agent.kind} response\n[[ROUNDRELAY_CONSENSUS:${agreed ? 'agree' : 'continue'}]]`,
sessionRef: runOptions.sessionRef || `${agent.kind}-session`,
}
}
const workspace = new LocalWorkspace(options)
const finished = []
workspace.on('run-finished', result => finished.push(result))
await workspace.refreshAgents()
const group = workspace.createGroup({
name: '不限轮次讨论', agentKinds: ['codex', 'hermes'], workdir: directory,
})
const started = await workspace.sendMessage({
groupId: group.id,
text: '讨论到达成共识',
mode: 'auto',
maxRounds: 1,
unlimitedRounds: true,
})
await firstCallEntered.promise
const active = workspace.snapshot().runs[0]
assert.deepEqual(started, {
started: true,
maxRounds: 0,
threadRootId: active.threadRootId,
unlimitedRounds: true,
})
assert.equal(active.currentRound, 1)
assert.equal(active.maxRounds, 0)
assert.equal(active.unlimitedRounds, true)
const pending = workspace.activeRuns.get(group.id).promise
firstCallGate.resolve()
await pending
assert.deepEqual(calls.map(call => call.agent.kind), ['codex', 'hermes', 'codex', 'hermes'])
assert.equal(finished[0].status, 'completed')
assert.equal(workspace.snapshot().messages.some(message => (
message.system?.key === 'system.autoRoundLimit'
)), false)
})
test('auto send rejects failed preflight without persisting a root or starting an Agent', async (t) => {
const { directory, calls, options } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
+82 -25
View File
@@ -88,12 +88,14 @@ function loadMain(userData, options = {}) {
const permissionHandlers = {}
const nativeImageCalls = []
const notificationInstances = []
const protocolHandlers = new Map()
const registeredSchemes = []
let readyCallback
let appReady = false
let quitCount = 0
let singleInstanceLockCalls = 0
let attachmentConstructionCount = 0
let providerConfigured = options.providerConfigured === true
const providerConfiguredKinds = new Set(options.providerConfigured === true ? ['hermes'] : [])
class TestWorkspace extends EventEmitter {
constructor(input) {
@@ -224,9 +226,9 @@ function loadMain(userData, options = {}) {
readWithMetadata(id) {
this.readWithMetadataCalls.push(id)
return {
metadata: this.metadataById.get(id)
metadata: options.mediaMetadata || this.metadataById.get(id)
|| { id, name: 'diagram.png', mimeType: 'image/png', size: 3 },
bytes: options.attachmentBytes || pngHeader(),
bytes: options.mediaBytes || options.attachmentBytes || pngHeader(),
}
}
discard(refs) {
@@ -268,14 +270,14 @@ function loadMain(userData, options = {}) {
constructor(input) {
this.input = input
this.saved = []
this.deleteCount = 0
this.deleted = []
this.statusCalls = []
providerInstances.push(this)
}
status(input) {
this.statusCalls.push(input)
return providerConfigured
status(kind, input) {
this.statusCalls.push({ kind, input })
return providerConfiguredKinds.has(kind)
? { ...PROVIDER_METADATA, encryptionAvailable: true, configured: true }
: {
provider: '', baseUrl: '', model: '',
@@ -283,19 +285,20 @@ function loadMain(userData, options = {}) {
}
}
save(input) {
this.saved.push(input)
providerConfigured = true
return this.status()
save(kind, input) {
this.saved.push({ kind, input })
providerConfiguredKinds.add(kind)
return this.status(kind)
}
delete() {
this.deleteCount += 1
providerConfigured = false
return this.status()
delete(kind) {
this.deleted.push(kind)
providerConfiguredKinds.delete(kind)
return this.status(kind)
}
envForAgent() {
envForAgent(kind) {
assert.equal(providerConfiguredKinds.has(kind), true)
return options.providerEnv || {
OPENAI_API_KEY: 'provider-key',
OPENAI_BASE_URL: PROVIDER_METADATA.baseUrl,
@@ -391,6 +394,10 @@ function loadMain(userData, options = {}) {
handle: (name, listener) => ipcHandlers.set(name, listener),
on: (name, listener) => ipcListeners.set(name, listener),
},
protocol: {
registerSchemesAsPrivileged: schemes => registeredSchemes.push(...schemes),
handle: (scheme, listener) => protocolHandlers.set(scheme, listener),
},
Menu: {
buildFromTemplate: template => template,
setApplicationMenu: () => {},
@@ -474,6 +481,8 @@ function loadMain(userData, options = {}) {
dialogCalls,
externalUrls,
permissionHandlers,
protocolHandlers,
registeredSchemes,
providerInstances,
runAgentCalls,
skillCatalogInstances,
@@ -532,6 +541,41 @@ test('a second desktop process exits before ready initialization', (t) => {
assert.equal(harness.windows.length, 0)
})
test('controlled media protocol serves attachment bytes with range support and rejects invalid ids', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'roundrelay-media-protocol-'))
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const mediaBytes = Buffer.from('ID3-audio-payload')
const { harness } = loadMain(directory, {
mediaMetadata: {
id: 'generated-audio', name: 'briefing.mp3', mimeType: 'audio/mpeg', size: mediaBytes.length,
},
mediaBytes,
})
assert.equal(harness.registeredSchemes.some(entry => (
entry.scheme === 'meldwork-media'
&& entry.privileges?.secure === true
&& entry.privileges?.standard === true
)), true)
await harness.ready()
const handler = harness.protocolHandlers.get('meldwork-media')
assert.equal(typeof handler, 'function')
const response = await handler(new Request(
'meldwork-media://attachment/generated-audio',
{ headers: { Range: 'bytes=4-8' } },
))
assert.equal(response.status, 206)
assert.equal(response.headers.get('content-type'), 'audio/mpeg')
assert.equal(response.headers.get('content-range'), `bytes 4-8/${mediaBytes.length}`)
assert.deepEqual(Buffer.from(await response.arrayBuffer()), mediaBytes.subarray(4, 9))
const readsBeforeInvalid = harness.attachmentInstances[0].readWithMetadataCalls.length
const invalid = await handler(new Request('meldwork-media://attachment/..%2Fsecret'))
assert.equal(invalid.status, 404)
assert.equal(harness.attachmentInstances[0].readWithMetadataCalls.length, readsBeforeInvalid)
})
test('every local IPC handler rejects an untrusted renderer before dispatch', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'roundrelay-ipc-security-'))
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
@@ -571,6 +615,10 @@ test('ready activates one fixed local workspace and loads the bundled frontend',
assert.equal(harness.workspaceInstances[0].refreshCount, 0)
assert.equal(harness.providerInstances[0].input.storagePath,
path.join(directory, 'roundrelay-provider.json'))
assert.deepEqual(
[...harness.providerInstances[0].input.allowedKinds].sort(),
['hermes', 'openclaw', 'qwen', 'workbuddy'].sort(),
)
assert.equal(harness.attachmentInstances[0].input.rootPath,
path.join(directory, 'attachments'))
assert.equal(harness.skillCatalogInstances[0].input.home,
@@ -858,25 +906,34 @@ test('Provider IPC accepts the complete user payload and supports local deletion
await harness.ready()
const input = { ...PROVIDER_METADATA, apiKey: 'renderer-provider-key' }
const initial = await harness.ipcHandlers.get('local-agent-provider:status')(harness.event())
const initial = await harness.ipcHandlers.get('local-agent-provider:status')(
harness.event(), 'hermes',
)
assert.equal(initial.configured, false)
assert.deepEqual(harness.providerInstances[0].statusCalls, [undefined])
assert.deepEqual(harness.providerInstances[0].statusCalls, [{ kind: 'hermes', input: undefined }])
await harness.ipcHandlers.get('local-agent-provider:probe')(harness.event())
await harness.ipcHandlers.get('local-agent-provider:probe')(harness.event(), 'hermes')
assert.deepEqual(harness.providerInstances[0].statusCalls, [
undefined,
{ probeEncryption: true },
{ kind: 'hermes', input: undefined },
{ kind: 'hermes', input: { probeEncryption: true } },
])
const saved = await harness.ipcHandlers.get('local-agent-provider:save')(
harness.event(), input,
harness.event(), 'hermes', input,
)
assert.deepEqual(harness.providerInstances[0].saved, [input])
assert.deepEqual(harness.providerInstances[0].saved, [{ kind: 'hermes', input }])
assert.equal(saved.configured, true)
assert.equal(harness.workspaceInstances[0].refreshCount, 1)
const deleted = await harness.ipcHandlers.get('local-agent-provider:delete')(harness.event())
assert.equal(harness.providerInstances[0].deleteCount, 1)
await assert.rejects(
async () => harness.ipcHandlers.get('local-agent-provider:status')(harness.event(), 'codex'),
{ message: 'PROVIDER_AGENT_UNSUPPORTED' },
)
const deleted = await harness.ipcHandlers.get('local-agent-provider:delete')(
harness.event(), 'hermes',
)
assert.deepEqual(harness.providerInstances[0].deleted, ['hermes'])
assert.equal(deleted.configured, false)
assert.equal(harness.workspaceInstances[0].refreshCount, 2)
})
+7 -4
View File
@@ -75,19 +75,22 @@ test('local preload exposes the local-only RoundRelay API and narrow Provider me
assert.equal('envForAgent' in api.localAgentProvider, false)
assert.equal('read' in api.localAgentProvider, false)
await api.localAgentProvider.status()
await api.localAgentProvider.probe()
await api.localAgentProvider.save({
await api.localAgentProvider.status('hermes')
await api.localAgentProvider.probe('hermes')
await api.localAgentProvider.save('hermes', {
provider: 'Example', baseUrl: 'https://api.example.com/v1',
model: 'example-model', apiKey: 'test-renderer-key',
})
await api.localAgentProvider.delete()
await api.localAgentProvider.delete('hermes')
assert.deepEqual(invocations.map(call => call.channel), [
'local-agent-provider:status',
'local-agent-provider:probe',
'local-agent-provider:save',
'local-agent-provider:delete',
])
assert.deepEqual(invocations.map(call => call.args[0]), [
'hermes', 'hermes', 'hermes', 'hermes',
])
})
test('local preload exposes cancellable read-only run lifecycle subscriptions', () => {
+72 -25
View File
@@ -29,7 +29,7 @@ function fixture(safeStorage = encryptedSafeStorage()) {
return {
directory,
storagePath,
store: new ProviderStore({ storagePath, safeStorage }),
store: new ProviderStore({ storagePath, safeStorage, allowedKinds: ['hermes', 'qwen'] }),
}
}
@@ -37,12 +37,12 @@ test('status exposes user-configured metadata without the API key', (t) => {
const { directory, store } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
assert.deepEqual(store.status(), {
assert.deepEqual(store.status('hermes'), {
provider: '', baseUrl: '', model: '', encryptionAvailable: null, configured: false,
})
store.save(credential())
const status = store.status()
store.save('hermes', credential())
const status = store.status('hermes')
assert.deepEqual(status, { ...PROVIDER, encryptionAvailable: true, configured: true })
assert.equal('apiKey' in status, false)
})
@@ -57,9 +57,9 @@ test('status defers safeStorage until explicitly requested when no credential ex
const { directory, store } = fixture(safeStorage)
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
assert.equal(store.status().encryptionAvailable, null)
assert.equal(store.status('hermes').encryptionAvailable, null)
assert.equal(encryptionChecks, 0)
assert.equal(store.status({ probeEncryption: true }).encryptionAvailable, true)
assert.equal(store.status('hermes', { probeEncryption: true }).encryptionAvailable, true)
assert.equal(encryptionChecks, 1)
})
@@ -68,7 +68,7 @@ test('save encrypts the API key, writes atomically, and restricts file permissio
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const apiKey = 'test-key-never-plaintext'
assert.deepEqual(store.save(credential(apiKey)), {
assert.deepEqual(store.save('hermes', credential(apiKey)), {
...PROVIDER, encryptionAvailable: true, configured: true,
})
const stored = fs.readFileSync(storagePath, 'utf8')
@@ -84,14 +84,14 @@ test('save accepts only the complete four-field Provider payload', (t) => {
const { directory, store } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
assert.throws(() => store.save({}), { message: 'PROVIDER_INVALID_CREDENTIAL' })
assert.throws(() => store.save(credential('')), { message: 'PROVIDER_INVALID_CREDENTIAL' })
assert.throws(() => store.save('hermes', {}), { message: 'PROVIDER_INVALID_CREDENTIAL' })
assert.throws(() => store.save('hermes', credential('')), { message: 'PROVIDER_INVALID_CREDENTIAL' })
assert.throws(
() => store.save({ ...credential(), extra: true }),
() => store.save('hermes', { ...credential(), extra: true }),
{ message: 'PROVIDER_INVALID_CREDENTIAL' },
)
assert.throws(
() => store.save({ apiKey: 'key', provider: 'Example', baseUrl: '', model: 'model' }),
() => store.save('hermes', { apiKey: 'key', provider: 'Example', baseUrl: '', model: 'model' }),
{ message: 'PROVIDER_INVALID_METADATA' },
)
})
@@ -99,9 +99,9 @@ test('save accepts only the complete four-field Provider payload', (t) => {
test('envForAgent decrypts the configured Provider', (t) => {
const { directory, store } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
store.save(credential('test-key-env'))
store.save('hermes', credential('test-key-env'))
assert.deepEqual(store.envForAgent(), {
assert.deepEqual(store.envForAgent('hermes'), {
OPENAI_API_KEY: 'test-key-env',
OPENAI_BASE_URL: PROVIDER.baseUrl,
OPENAI_MODEL: PROVIDER.model,
@@ -112,13 +112,13 @@ test('encryption unavailability fails closed for status, save, and reads', (t) =
const { directory, storagePath, store } = fixture(encryptedSafeStorage(false))
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
assert.deepEqual(store.status({ probeEncryption: true }), {
assert.deepEqual(store.status('hermes', { probeEncryption: true }), {
provider: '', baseUrl: '', model: '', encryptionAvailable: false, configured: false,
})
assert.throws(() => store.save(credential()), {
assert.throws(() => store.save('hermes', credential()), {
message: 'PROVIDER_ENCRYPTION_UNAVAILABLE',
})
assert.throws(() => store.envForAgent(), {
assert.throws(() => store.envForAgent('hermes'), {
message: 'PROVIDER_ENCRYPTION_UNAVAILABLE',
})
assert.equal(fs.existsSync(storagePath), false)
@@ -132,7 +132,7 @@ test('safeStorage encryption failures do not leave a credential file', (t) => {
})
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
assert.throws(() => store.save(credential()), {
assert.throws(() => store.save('hermes', credential()), {
message: 'PROVIDER_ENCRYPTION_FAILED',
})
assert.equal(fs.existsSync(storagePath), false)
@@ -145,10 +145,10 @@ test('corrupt credentials stay unavailable and are never returned', (t) => {
mode: 0o600,
})
assert.deepEqual(store.status(), {
assert.deepEqual(store.status('hermes'), {
provider: '', baseUrl: '', model: '', encryptionAvailable: true, configured: false,
})
assert.throws(() => store.envForAgent(), {
assert.throws(() => store.envForAgent('hermes'), {
message: 'PROVIDER_CREDENTIAL_UNAVAILABLE',
})
})
@@ -157,14 +157,14 @@ test('delete removes the local credential even when encryption is unavailable',
const safeStorage = encryptedSafeStorage()
const { directory, storagePath, store } = fixture(safeStorage)
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
store.save(credential())
store.save('hermes', credential())
safeStorage.isEncryptionAvailable = () => false
assert.deepEqual(store.delete(), {
assert.deepEqual(store.delete('hermes'), {
provider: '', baseUrl: '', model: '', encryptionAvailable: false, configured: false,
})
assert.equal(fs.existsSync(storagePath), false)
assert.doesNotThrow(() => store.delete())
assert.doesNotThrow(() => store.delete('hermes'))
})
test('Provider base URL must use HTTPS or loopback HTTP without embedded data', (t) => {
@@ -172,14 +172,61 @@ test('Provider base URL must use HTTPS or loopback HTTP without embedded data',
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
assert.throws(
() => store.save(credential('key', { ...PROVIDER, baseUrl: 'http://provider.example/v1' })),
() => store.save('hermes', credential('key', { ...PROVIDER, baseUrl: 'http://provider.example/v1' })),
{ message: 'PROVIDER_INSECURE_BASE_URL' },
)
assert.doesNotThrow(() => store.save(credential('key', {
assert.doesNotThrow(() => store.save('hermes', credential('key', {
...PROVIDER, baseUrl: 'http://127.0.0.1:11434/v1',
})))
assert.throws(
() => store.save(credential('key', { ...PROVIDER, baseUrl: 'https://[email protected]/v1' })),
() => store.save('hermes', credential('key', { ...PROVIDER, baseUrl: 'https://[email protected]/v1' })),
{ message: 'PROVIDER_INVALID_METADATA' },
)
})
test('Provider credentials are isolated per Agent', (t) => {
const { directory, store } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
store.save('hermes', credential('hermes-key'))
store.save('qwen', credential('qwen-key', { ...PROVIDER, model: 'qwen-model' }))
assert.equal(store.status('hermes').model, PROVIDER.model)
assert.equal(store.status('qwen').model, 'qwen-model')
assert.equal(store.envForAgent('hermes').OPENAI_API_KEY, 'hermes-key')
assert.equal(store.envForAgent('qwen').OPENAI_API_KEY, 'qwen-key')
store.delete('hermes')
assert.equal(store.status('hermes').configured, false)
assert.equal(store.status('qwen').configured, true)
})
test('legacy shared credentials migrate into independent Agent entries on mutation', (t) => {
const { directory, storagePath, store } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const encrypted = encryptedSafeStorage().encryptString('legacy-key').toString('base64')
fs.writeFileSync(storagePath, JSON.stringify({
version: 1,
...PROVIDER,
encrypted,
}), { mode: 0o600 })
assert.equal(store.status('hermes').configured, true)
assert.equal(store.status('qwen').configured, true)
store.save('qwen', credential('new-qwen-key', { ...PROVIDER, model: 'new-qwen-model' }))
const migrated = JSON.parse(fs.readFileSync(storagePath, 'utf8'))
assert.equal(migrated.version, 2)
assert.deepEqual(Object.keys(migrated.agents).sort(), ['hermes', 'qwen'])
assert.equal(store.envForAgent('hermes').OPENAI_API_KEY, 'legacy-key')
assert.equal(store.envForAgent('qwen').OPENAI_API_KEY, 'new-qwen-key')
})
test('Provider operations reject unsupported Agent kinds', (t) => {
const { directory, store } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
assert.throws(() => store.status('codex'), { message: 'PROVIDER_AGENT_UNSUPPORTED' })
assert.throws(() => store.save('../hermes', credential()), { message: 'PROVIDER_AGENT_UNSUPPORTED' })
assert.throws(() => store.delete(''), { message: 'PROVIDER_AGENT_UNSUPPORTED' })
})
-710
View File
@@ -1,710 +0,0 @@
# RoundRelay 产品评审与迭代战略(内部版)
> 状态:战略草案,供两院内部决策与工程排期使用
>
> 产品名:RoundRelay
>
> 中文工作名:**群智接力**
>
> 证据基线:2026-07-30 的仓库与工作区快照
>
> 适用阶段:内部 Alpha 到两院封闭试点
>
> 复审节奏:每 4 周复审一次;当产品边界、数据政策或主目标用户发生变化时立即复审
## 0. 一页结论
### 决策建议
**建议有条件继续投入,暂不进入公开发布。** RoundRelay 已经具备一个可信本地桌面产品的技术骨架:Electron 主进程承担权限边界,本地 Agent 可被发现与调用,首次引导、单聊和群聊、`@` Skill、图片附件与会话引用已经形成实现和自动化验证,工作目录默认只读,自动讨论有轮数、时间和停止上限。这些能力足以支持院内 Alpha,不足以证明“多 Agent 一定比单 Agent 更有效”,也不足以支撑高敏感数据、Windows 正式发布或无人监管的自主辩论。
下一阶段不应继续以“支持更多 Agent、增加更多聊天功能”为主线,而应验证一个更窄的产品命题:
> **RoundRelay(群智接力)是一款面向两院知识工作者的本地优先多智能体协作桌面端:在用户掌控上下文、工作目录权限和模型去向的前提下,将已有异构本地 Agent 组织为可持续对话、分工接力和可审计审校。**
战略重点是从“多个 Agent 在一个聊天框里轮流说话”,升级为“围绕高价值任务形成可验证的工作结果”。若 12 周试点不能证明它相对“直接使用最强单 Agent”在质量、速度或风险发现上至少有一项达到预设门槛且方向稳定的优势,应收缩为统一的本地 Agent 工作台,不继续投入复杂自治编排。
### 战略范围说明
本文评审对象是当前仓库中的 **RoundRelay 本地桌面产品**,不是最初设想的“云 Agent + 本地 Agent + 多个人类同事”完整两院协作平台。两者应按“本地切入点与上层平台”的关系管理,而不是混为同一个已交付产品:
- RoundRelay 先验证本地 Agent 互联、任务接力、权限控制和多 Agent 净收益,是可独立使用的产品,也是未来混合平台可复用的桌面执行节点。
- 原始两院平台若继续推进,负责组织身份、同事协作、云 Agent、任务发布和跨端共享;它不能绕过本地端的数据去向确认与执行授权。
- 在 RoundRelay 尚未证明高价值任务收益、协议稳定性和安全边界前,不把云端群聊、多用户实时协作或跨设备同步纳入当前路线图。
- 后续若立项混合平台,应单独定义身份、消息同步、权限继承、云本地路由和审计架构,不以“给 RoundRelay 加一个服务器”代替战略与安全评审。
### 当前阶段判断
| 维度 | 判断 | 说明 |
| --- | --- | --- |
| 技术可行性 | 中高 | 本地执行、IPC 边界、持久化、首次引导、Skill/图片上下文、适配器和有界自动讨论已有代码与测试证据 |
| 发布成熟度 | 中低 | macOS 仅适合内部验证;未完成正式签名、公证、自动升级和完整真机矩阵 |
| 用户价值证据 | 低 | 仓库内没有访谈、行为数据、任务效果对照或留存证据 |
| 差异化潜力 | 中高 | “本地控制 + 异构 Agent + 结构化审校”组合有价值,但仅支持多个 CLI 很容易被复制 |
| 安全可信度 | 中 | Electron 边界和密钥存储较强,但 Agent 仍以本机用户权限运行,聊天数据未做应用层加密,安装源未锁定摘要 |
| 自主辩论成熟度 | 低 | 当前是顺序轮询、提示词约束和一致同意标记,不是角色化、证据化、可评测的辩论协议 |
### 投资条件
未来 12 周只在以下条件下继续加码:
1. 找到至少 2 个高频且高价值的院内任务,用户愿意每周重复使用。
2. 对照最强单 Agent,RoundRelay 在盲评质量、风险发现或总用时上至少一项达到 20% 的方向性提升门槛,且没有明显增加返工;正式扩大前必须同时报告样本量、评审一致性和区间估计。
3. 真实 Agent 调用失败率、写入越权、敏感信息外发和安装供应链风险达到本文件定义的发布门槛。
4. 用户能正确理解“本地优先不等于完全离线”,不会把第三方 Provider 出网误认为 RoundRelay 云端上传。
### 明确不做
- 不做新的院内即时通信工具,不替代钉钉或现有协作平台。
- 不做远程多人群聊、云端频道和云端会话存储。
- 不把“无人值守自治”作为近期卖点;任何多轮运行都必须可见、可停、有预算、有责任人。
- 不以支持 Agent 数量作为核心成功指标。
- 不承诺 RoundRelay 能提供操作系统级沙箱;上游 CLI 的权限标志必须经过真机验证。
- 不在缺少效果对照前建设复杂的 Agent 市场、公共社区或跨设备同步。
### 目标版本内部发布稿
> RoundRelay 面向两院首批试点人员开放内部版本。研究员、工程师和产品人员可以在自己的电脑上,把已经安装的 Codex、Hermes、WorkBuddy、OpenCode 等 Agent 放进同一任务工作台,明确选择工作目录、读写权限和模型去向,让不同 Agent 分别提出方案、审查风险、补充证据并由用户确认最终结果。RoundRelay 不建立云端会话服务,也不替代现有多人协作工具;会话与编排状态保存在本机,但所选 Agent 或模型 Provider 仍可能访问网络,产品会在运行前清楚显示数据去向和权限范围。
### 名称与视觉系统
- **产品名:RoundRelay。** `Round` 同时指多轮讨论、不同视角和围绕同一任务的回合;`Relay` 指上下文、方案、证据和产物在异构 Agent 之间接力。名称直接表达“轮次讨论 + 能力接力”,比泛化的 Agent Hub、Agent Chat 更能承载产品差异。
- **中文工作名:群智接力。** 适合两院内部沟通,强调集体审校和结果传递,不暗示无人自治。是否作为正式对外中文名,应在试点前由品牌负责人确认。
- **Logo:3D Relay Fold。** 三段折叠箭带形成持续循环,分别表达提出、质询和收敛,也可理解为多个 Agent 围绕同一任务交换主导权。薄荷青玻璃金属与深石墨底提供未来软件感,明确避开齿轮、线路板和工业机械意象。
- **资产规范。** `roundrelay-app.png` 使用透明画布的 3D 应用图标,`roundrelay-favicon.png` 是 64px 小尺寸版本,`roundrelay.png` 保留为深色背景上的产品主标;首启三张 GPT Image 2 示意图沿用明亮 3D 视觉。缩小场景可使用同一几何标记的简化表达,但不得改成齿轮、机械臂或复杂网络拓扑。
## 1. 事实基线:现在到底有什么
### 1.1 证据分级
本文采用四级证据,避免把愿景写成现状:
| 级别 | 定义 | 可使用的表述 |
| --- | --- | --- |
| A:代码 + 自动化测试 + 已执行验证 | 有实现、测试,且仓库记录了实际执行结果 | “已实现并验证” |
| B:代码 + 自动化测试 | 有实现和测试,但没有完整真机或生产环境验证 | “已实现,待真机验证” |
| C:工作区开发中 | 代码处于未提交或未闭环状态,关键调用链缺失 | “开发中,不可视为已交付” |
| D:规划或假设 | 没有实现证据,或只有产品意图 | “计划、假设、待验证” |
### 1.2 能力盘点
| 能力 | 状态 | 证据与边界 | 产品判断 |
| --- | --- | --- | --- |
| 本地优先 Electron 桌面端 | A | `README.md`、`documentation/architecture.md`、`desktop/src/main.cjs`;无账号、应用服务器、远程会话服务 | 是产品底座,应保持,不应为短期便利引入必需云服务 |
| 受限 preload 与主进程 IPC | A | `desktop/src/preload.cjs`、`desktop/src/main.cjs`、主进程安全测试 | 当前最有价值的可信基础之一 |
| 9 类本地 Agent 检测与调用适配 | B | `desktop/src/cli-adapters.cjs`、`desktop/src/agent-installer.cjs` 及单元测试 | 代码支持不等于九类 Agent 在目标设备上都稳定可用;必须以兼容矩阵对外呈现 |
| 本地 Agent 单聊 | B | `LocalWorkspace.createGroup` 的 `direct` 类型、会话引用复用测试、前端直接会话入口 | 可作为最小价值入口,但不能只做成 CLI 包装器 |
| 单人 + 多 Agent 群聊 | B | 群组、消息、话题和 Agent 列表本地持久化;每个群组只有一个本机人类操作者 | “人 + Agent 群”不是多个人类远程协作群,必须在文案中说清楚 |
| 手动多 Agent 回复 | B | `LocalWorkspace.sendMessage` 按目标 Agent 顺序调用,失败相互隔离 | 适合可控审校;顺序调用延迟会随 Agent 数量线性增长 |
| 有界自动讨论 | B | `LocalWorkspace.startAuto`:最多 12 轮、总时长 30 分钟、可停止、全员同轮显式同意才提前结束 | 是“自动轮询讨论”,不是成熟自主辩论;一致同意标记只能表示模型自报状态 |
| 话题与 Agent 原生会话隔离 | B | 每个群组、话题、Agent 保存独立会话引用;最近上下文限制为 16 条、12000 字符 | 有利于连续工作,但长历史压缩、召回质量和性能尚未验证 |
| 工作目录默认只读、显式开启写入 | B | 群组级 `allowWrite` 与各适配器权限参数 | 产品方向正确,但不是 OS 沙箱;每类 Agent 必须独立做破坏性写入验证 |
| Provider 密钥安全存储 | A/B | `safeStorage`、0600 文件、URL 校验及测试;真实操作系统和企业镜像仍需验证 | 应继续保持失败关闭,不得新增明文兜底 |
| 固定白名单 Agent 安装 | B | 固定 npm 包或 HTTPS 安装脚本、敏感环境过滤、取消与安装后验证 | 上游包和脚本可变且未校验摘要,是内部扩散前的高优先级风险 |
| macOS 打包 | A(内部验证) | 仓库记录 arm64 DMG/ZIP、打包 UI 冒烟和 ad-hoc 签名 | 能内测,不能等同公开可分发版本 |
| Windows 发行 | D/B | 有 Windows 路径与安装逻辑,但无真实 Windows 发布设备验证、无 CI 发行证据 | 不应在宣传中写“正式支持 Windows” |
| 图片附件 | A/B | 已实现选择器/粘贴、私有附件存储、尺寸与完整性校验、ID 预览、消息持久化、删除回收、并发导入门槛、全失败消息防重复、等上下文预检,以及 Codex/Hermes/OpenCode 参数路由;前端 54/54、桌面 219/219 自动化已通过,打包版剪贴板导入和预览 IPC 已冒烟 | 已进入 Alpha 能力;仍须在目标设备用真实 Agent 验证版本兼容、Provider 外发和混合群组失败口径 |
| 本地 Skill 引用 | A/B | 已实现已安装 Agent 的本地目录扫描、清洗后的 IPC、`@` 选择、每目标重新校验、消息提示和目标隔离;Hermes 原生 `--skills` 预加载及其他 Agent 的定向上下文提示有自动化覆盖 | 已进入 Alpha 能力;仍须验证各 Agent 真实 Skill 发现规则、执行效果、提示注入风险和版本差异 |
| 首次引导 | A | 三张 GPT Image 2 使用示意图、单次启动检测、真实完成门槛、焦点/滚动/返回键行为已有前端测试,且打包 `.app` 已确认图片加载、背景 inert、无横向溢出和 `Start using` 状态 | 已交付为激活入口;下一步衡量首次回复时间和真实任务完成率,而不是继续扩页 |
| 结构化质询、证据核验、反方角色 | D | 当前没有角色、论点、证据、异议和裁决对象模型 | 是下一阶段核心产品能力,不应把现有自动讨论改名冒充 |
| 多个人类实时协作 | 明确不支持 | 架构文档明确只有一个本机人类操作者,无远程参与者或云频道 | 保持边界,通过现有协作工具分享结果 |
| 会话应用层加密、不可篡改审计 | D | 会话 JSON 未做应用层加密;只有本地状态,没有独立审计日志 | 高敏感任务进入试点前需确定制度与技术门槛 |
### 1.3 现有自动讨论的准确描述
当前自动讨论具有真实实现,不应被低估,也不应被包装过度:
1. 用户先发起一个话题,再选择自动讨论轮数。
2. 系统按群组 Agent 顺序逐个调用,每个 Agent 看到同一话题下有限的最近对话。
3. 提示词要求 Agent 在最后输出 `agree` 或 `continue` 标记。
4. 只有所有 Agent 在同一完整轮次成功返回且都标记 `agree`,系统才提前结束。
5. 系统支持失败隔离、后续轮次重试、用户停止、12 轮硬上限和 30 分钟总上限。
6. 带图片的根消息会在启动前检查所有 Agent 能否获得同一组图片;任一目标不支持或数量不足时整轮不启动。Skill 保持目标隔离,不会跨 Agent 传播。
它尚未做到:指定提议者/反方/证据审查者、验证引用来源、判断分歧是否真正解决、比较方案得分、保留少数意见、生成可追踪结论、针对同模型相关性做校正。因此战略上应称为“有界多 Agent 接力讨论”,将“结构化质询与收敛”作为未来目标。
### 1.4 证据缺口
仓库目前没有以下产品证据:
- 至少 5 次目标用户访谈及原始任务样本。
- 真实用户留存、重复任务率、每周任务完成数。
- RoundRelay 与“最强单 Agent”在同一任务上的盲评对照。
- 多 Agent 发现了多少单 Agent 未发现且被人类验证的问题。
- 用户为切换 CLI、复制上下文和合并结果实际花费的时间。
- 高敏感数据是否允许进入第三方 Provider 的院内政策结论。
- 九类 Agent 在目标 macOS/Windows 设备上的完整真机兼容结果。
因此,本文对技术现状的置信度较高,对需求强度和留存的置信度较低。方向判断约 70% 置信度,首要场景排序约 50%,指标目标属于试点假设,不是历史基线。
## 2. 问题定义
### 2.1 核心问题假设
两院内部已经使用多个本地 Agent 的研究员、工程师和产品人员,在高价值任务上面临三类摩擦:
1. **上下文碎片化**:不同 CLI、终端和工具各自保存会话,用户反复复制背景、约束和中间结论。
2. **结果整合成本高**:多个 Agent 可以给出不同答案,但用户仍需手工组织提议、反驳、验证和最终结论。
3. **信任边界不清楚**:用户知道工具“在本机运行”,却未必知道所选 Agent、Provider、安装脚本和工作目录权限具体会把什么数据带到哪里。
这些是待验证问题,不是已证实事实。首轮发现研究必须回答:问题发生频率多高、现有替代方案是什么、什么任务的失败成本足够高,值得承担多 Agent 的额外时间和模型费用。
### 2.2 要交付的用户结果
RoundRelay 的用户结果不是“产生更多消息”,而是:
> 用户用更少的切换和整合成本,得到一个经过独立质询、知道数据去向、能够追溯过程并可以直接继续执行的结果。
### 2.3 三层价值
| 层级 | 用户价值 | 当前成熟度 | 下一步 |
| --- | --- | --- | --- |
| 统一入口 | 首次引导、发现、安装、单聊和继续使用不同本地 Agent | 中 | 做真机兼容与首次成功率,不再增加泛化引导页 |
| 协作接力 | 多 Agent 在同一任务上下文中分工、回应和延续 | 中低 | 从聊天串升级为任务、角色和结果对象 |
| 可信审校 | 有证据的质询、分歧保留、可解释收敛和权限审计 | 低 | 建立结构化协议、评测和安全门槛 |
战略上,统一入口是获客和激活能力,协作接力是使用价值,可信审校才可能形成长期差异化。
## 3. 目标用户与优先场景
### 3.1 首要用户
**首要用户:已经每周使用至少两个本地 Agent CLI、处理非公开代码或文档、需要对重要结果负责的两院知识工作者。**
优先选择这类用户,因为他们已经承担安装、认证和本地环境成本,能立即感知切换与整合摩擦,也更可能评价多 Agent 是否真的增加价值。
| 用户 | 核心任务 | 现有替代 | 成功结果 | 不适合的原因 |
| --- | --- | --- | --- | --- |
| 研发工程师/架构师 | 方案设计、代码审查、故障根因、变更风险 | 多个终端、IDE Agent、人工复制上下文 | 找出关键缺陷并形成可执行修改计划 | 简单补全或机械改动用单 Agent 更快 |
| AI 产品经理/研究运营 | PRD 红队、需求澄清、竞品假设、验收标准 | 单模型聊天、人工评审会 | 更早暴露口径、依赖和失败条件 | 没有原始证据时,多 Agent 只会放大推测 |
| 研究员/技术研究人员 | 研究设计、论证审校、复现实验计划 | 文献工具、单模型问答、同事评审 | 形成带未决问题和证据缺口的审校结果 | 需要在线检索但未配置可信工具时,结论质量不可控 |
| 项目负责人 | 重大方案预演、风险清单、决策备忘录 | 会议、专家咨询、人工汇总 | 缩短准备时间并保留少数意见 | 最终责任不能交给 Agent 共识标记 |
### 3.2 优先任务
| 优先级 | 场景 | 为什么适合多 Agent | 目标产物 | 初始试点建议 |
| --- | --- | --- | --- | --- |
| P0 | 代码/架构变更审查 | 可分为实现理解、反例、测试、安全和整合 | 风险清单、证据位置、修改建议、未决项 | 首批必测 |
| P0 | PRD/方案红队 | 不同角色可独立挑战目标、范围、依赖和验收 | 问题列表、决策建议、修订版验收标准 | 首批必测 |
| P1 | 故障根因与复盘预演 | 多路径假设和反证有价值 | 假设树、验证步骤、置信度 | 有真实脱敏样本后测试 |
| P1 | 研究设计与论证审校 | 需要方法、证据和反方视角 | 证据缺口、偏差风险、复现计划 | 需接入可靠资料后测试 |
| P2 | 长周期自主研究/开发 | 可能受益于多轮迭代 | 可执行成果与完整审计 | 仅在前述任务成立后探索 |
### 3.3 非目标用户与非目标场景
- 只使用一个 Agent、主要做低风险简单问答的用户。
- 需要手机端、多人实时聊天、云端共享频道的团队。
- 希望系统在无人监督下长期运行并直接提交高风险修改的用户。
- 需要 RoundRelay 代管模型账号、统一结算或组织权限的企业客户。
- 把“多 Agent 回复更多”本身当作价值的展示型场景。
## 4. 定位、边界与差异化
### 4.1 类别定位
RoundRelay 不应定位为“AI 群聊软件”,也不应定位为“另一个云端 Agent 编排平台”。更准确的类别是:
> **本地优先的多 Agent 任务审校与接力工作台。**
“聊天”是交互外壳,“任务结果、权限边界和审校过程”才是产品本体。
### 4.2 替代方案比较
| 替代方案 | 擅长什么 | RoundRelay 必须胜出的地方 | RoundRelay 应主动承认的劣势 |
| --- | --- | --- | --- |
| 分别使用各 Agent CLI/终端 | 原生能力完整、用户熟悉、迭代最快 | 减少上下文搬运、统一任务连续性、形成跨 Agent 结果 | 原生 CLI 新功能会先到上游,适配层可能滞后 |
| 单一 IDE/代码 Agent | 深度编辑、工程上下文和操作闭环 | 对重要方案提供独立质询和异构视角 | 日常编码延迟更低,简单任务体验更顺畅 |
| 云端多 Agent 框架/平台 | 集中部署、远程协作、自动化扩展 | 本地状态、用户掌控权限、复用现有 CLI 与认证 | 不提供服务器级调度、组织管理和跨设备协同 |
| 通用聊天/协作工具 | 多人沟通、通知、文件共享、组织关系 | 在本机执行任务并保留 Agent 运行上下文 | 不应复制即时通信能力 |
| 人工专家评审 | 责任清晰、经验深、可处理组织语境 | 降低预审成本,让人工聚焦高价值判断 | 不能取代专家责任和最终审批 |
### 4.3 差异化组合
单个能力都不构成护城河,真正值得投入的是以下组合:
1. **本地控制面**:会话、任务状态、工作目录选择和编排在本机;没有 RoundRelay 云端会话依赖。
2. **异构原生 Agent**:复用用户已经安装、认证和信任的 Agent,而不是把多个相同模型包装成不同角色。
3. **结构化审校协议**:把提议、反例、证据、异议、裁决和最终产物变成可追踪对象。
4. **权限与数据去向可见**:每次运行明确显示 Agent、Provider、网络去向、目录权限和可写范围。
5. **真实任务评测资产**:积累两院场景的脱敏任务集、质量量表、失败样本和适配兼容数据。
最容易被复制的是“支持九个 Agent”和“一个群聊窗口”;最难复制的是经过真实任务证明的协议、评测集、政策适配和用户信任。
### 4.4 成本定位
RoundRelay 不追求最低推理成本,而追求高价值任务的风险降低和整合时间缩短。默认仍应优先单 Agent,只有在以下条件满足时才建议升级为多 Agent:
- 任务失败代价高;
- 存在多个合理方案或明显盲区;
- 需要独立审查或反证;
- 用户愿意用更长等待换取更高置信度。
产品需要显示预计 Agent 数、轮数和可能耗时,避免“自动讨论”成为不可控的成本放大器。
### 4.5 Can't / Won't
竞争者“不会做”或“难以同时做好”的部分,才值得作为长期防御:
- 云端平台通常更适合集中管理,不一定愿意让本地 CLI、会话和目录成为一等公民。
- 单 Agent 厂商不会优先支持竞争 Agent 的平等协作和结果对照。
- 通用聊天工具不会深度承担 Agent 进程、权限、会话恢复和工作区副作用。
- RoundRelay 自己也明确不做云端多人通信、模型托管和无限自治,以换取本地可信和产品聚焦。
## 5. 产品原则
1. **结果先于对话。** 每次高价值任务都应有目标、产物、验收和未决问题,不能以消息数量衡量成功。
2. **单 Agent 默认,多 Agent 升级。** 简单任务不应强制组队;系统应解释什么时候值得增加 Agent。
3. **人类拥有目标、权限和最终裁决。** Agent 可以提议和质询,不能替用户承担责任。
4. **本地优先必须如实表达。** RoundRelay 不上传会话到自己的云端,不代表所选 Agent 或 Provider 不出网。
5. **权限必须可见、最小且可撤回。** 默认只读;写入必须显式、可追踪、可比较、可回滚。
6. **异构必须有实质意义。** 同一底层模型、同一提示词产生的多个角色不能被包装成“独立专家”。
7. **分歧比虚假共识更有价值。** 允许保留少数意见、证据不足和无法裁决,不以全员同意作为唯一成功条件。
8. **协议状态由程序管理。** 轮次、角色、预算、停止条件和失败处理应由确定性状态机控制,不能完全依赖提示词自报。
9. **可复现优先于炫技。** 记录 Agent/版本/模型/Provider/权限/上下文范围和产物,支持重跑与对照。
10. **保持单机架构简单。** 在证明需要前,不引入服务器、数据库集群、远程队列或微服务。
## 6. 目标产品模型
### 6.1 从“群聊”升级为“任务”
建议逐步引入以下本地对象,仍可保存在单机持久化层,不需要服务器:
| 对象 | 关键字段 | 作用 |
| --- | --- | --- |
| 任务 Task | 目标、背景、约束、验收、敏感级别、预算 | 防止对话偏离结果 |
| 参与者 Participant | Agent 类型、版本、模型/Provider、能力、角色 | 解释每个观点来自谁、能做什么 |
| 上下文包 Context Pack | 用户消息、选择文件、Skill、历史摘要、外发范围 | 让用户知道 Agent 实际收到什么 |
| 运行 Run | 协议、轮次、超时、调用序列、权限、状态 | 提供可停、可恢复、可审计的执行单元 |
| 论点 Claim | 结论、依据、提出者、置信度 | 支持质询和合并 |
| 异议 Objection | 针对论点、反例、严重度、状态 | 防止“大家都同意”掩盖未解决问题 |
| 证据 Evidence | 来源、片段、文件位置、验证状态 | 区分事实、推断和幻觉 |
| 产物 Artifact | 文档、代码 diff、清单、决策备忘录 | 把对话转为可继续工作的结果 |
| 裁决 Decision | 采用、拒绝、保留意见、责任人 | 明确人类最终责任 |
### 6.2 推荐的结构化质询协议
首个协议只做一个,不建设通用工作流编辑器。建议选择“方案红队”协议:
```text
用户定义任务与验收
↓
提议者生成初稿
↓
反方寻找关键反例和失败条件
↓
证据审查者核对引用、代码位置或数据口径
↓
整合者合并结论、保留未解决异议
↓
用户接受、要求重跑或终止
```
程序必须控制:角色分配、每步输入、最大轮数、每 Agent 超时、总预算、失败降级和停止条件。提示词负责内容生成,不负责决定系统是否“真的达成共识”。
### 6.3 停止条件
任一条件满足即可停止,不要求全员一致:
- 用户接受当前产物;
- 所有高严重度异议已解决或明确转为“需人工判断”;
- 新一轮没有新增有效论点或证据;
- 达到时间、轮数或调用预算;
- 关键 Agent 连续失败;
- 数据去向或权限条件发生变化,需要用户重新确认。
### 6.4 能力清单而非统一假设
每个 Agent 应有可机器读取的能力清单,至少包括:
- 操作系统与已验证版本;
- 是否支持原生会话恢复;
- 是否支持图片及数量限制;
- 是否支持本地 Skill 引用;
- 只读和写入模式的实际保障等级;
- 可使用的 Provider 与是否出网;
- 最大运行时长、取消可靠性和已知兼容问题。
混合群组发送图片、Skill 或写入请求前,系统应按目标 Agent 显示兼容结果。不能让不支持某能力的 Agent 静默失败,也不能把同一附件无差别传给所有 Agent。
## 7. 分阶段路线图
### 7.1 总体节奏
| 阶段 | 时间 | 目标 | 主要交付 | 决策门 |
| --- | --- | --- | --- | --- |
| 阶段 0:事实固化 | 第 0-2 周 | 把 Alpha 现状变成可重复验证的基线 | 真机矩阵、数据流说明、现有开发分支闭环、任务研究 | 能否稳定完成首批真实任务 |
| 阶段 1:可信工作台 | 第 3-6 周 | 让目标用户可靠完成单聊和受控群聊 | 能力清单、运行前确认、结果/产物、恢复与删除、内部签名包 | 激活和安全门槛 |
| 阶段 2:任务化协作 | 第 7-12 周 | 证明多 Agent 比人工切换更有价值 | 两个任务模板、角色分工、结果合成、评测埋点 | 对照实验是否达标 |
| 阶段 3:结构化质询 | 第 13-20 周 | 验证有证据的多轮质询与收敛 | 状态机、论点/异议/证据、人工裁决、预算 | 质量提升是否覆盖延迟和成本 |
| 阶段 4:发布扩展 | 达标后 | 扩大两院使用或准备外部发布 | 公证签名、升级、Windows、政策包、运维手册 | 组织是否愿意长期维护 |
阶段 3 和阶段 4 不是承诺项。阶段 2 的实验证据不达标时,应停止自治扩展。
### 7.2 P0:现在必须做(第 0-6 周)
| 优先级 | 工作项 | 成功标准 | 负责人角色 | 时间 |
| --- | --- | --- | --- | --- |
| P0 | 建立真实 Agent 兼容矩阵 | 目标 macOS 设备逐一验证检测、认证、首次调用、续聊、取消、只读、写入;未通过者默认隐藏或标实验 | Agent 集成工程师 | 2 周 |
| P0 | 完成图片、Skill 与首次引导的真机验收 | 现有 54/54 前端、219/219 桌面和打包 UI/IPC 冒烟保持通过;Codex/Hermes/OpenCode 图片、Hermes 原生 Skill、其他 Agent 定向 Skill 提示在目标 macOS 上逐项验证,失败能力明确降级 | 桌面工程师 + Agent 集成工程师 | 1 周 |
| P0 | 运行前数据去向与权限确认 | 用户能看到目标 Agent、Provider、是否出网、上下文来源和目录读写权限 | 产品 + 桌面工程师 | 3 周 |
| P0 | 任务结果对象 | 每次运行可保存“结论、异议、下一步、产物”;不是只能翻聊天记录 | 前端/桌面工程师 | 4 周 |
| P0 | 安装供应链收敛 | 安装版本可追踪;脚本/下载件校验摘要或签名;失败不执行 | 安全评审 + 桌面工程师 | 4 周 |
| P0 | 本地数据生命周期 | 导出、删除、备份/恢复行为清晰;异常退出后保留最后有效状态 | 桌面工程师 | 4 周 |
| P0 | 内部可分发 macOS 包 | 完成院内认可的签名/分发流程,干净设备可安装,升级/回退有方案 | 发布工程师 | 6 周 |
| P0 | 发现研究与基线任务集 | 8-12 次访谈、20 个脱敏任务、现有流程耗时和质量基线 | PM + 研究支持 | 2 周 |
### 7.3 P1:随后验证(第 7-12 周)
| 优先级 | 工作项 | 成功标准 | 负责人角色 | 时间 |
| --- | --- | --- | --- | --- |
| P1 | “代码/架构审查”任务模板 | 能生成有文件位置、严重度、证据和修改建议的结果 | 产品 + Agent 工程师 | 3 周 |
| P1 | “PRD/方案红队”任务模板 | 能区分事实、假设、反例、依赖、非目标和未决项 | 产品 + Agent 工程师 | 3 周 |
| P1 | 角色化接力 | 用户可选择或接受推荐角色;每个角色输入输出清晰 | Agent 工程师 | 4 周 |
| P1 | 结果对照与盲评 | 同一任务可对照单 Agent 与 RoundRelay 结果,评审人不知道来源 | PM + 评测负责人 | 4 周 |
| P1 | 运行成本与时间预算 | 开始前显示预算,运行中可见进度,达到上限自动停止 | 桌面工程师 | 3 周 |
| P1 | 本地运行来源记录 | 保存 Agent/版本/模型/Provider/权限/协议/产物引用,支持导出 | 桌面工程师 | 4 周 |
### 7.4 P2:有证据后再做(第 13-20 周)
- 论点、异议、证据和裁决的结构化状态机。
- 基于严重度和未解决问题的停止条件,而不是单一共识标记。
- 任务中途的人类检查点、重定向和角色替换。
- 同模型/同 Provider 相关性提示和角色多样性约束。
- 长历史摘要与上下文选择,但必须可见、可纠正、可重放。
- 对失败 Agent 的降级策略和部分结果交付。
- 适用于两院具体工作流的本地策略包与模板包。
### 7.5 Later:暂不排期
- Windows 正式发行和自动升级。
- 远程多人协作、跨设备同步、组织账号、云端会话。
- 自定义可视化工作流编排器。
- 公共 Agent/Skill 市场。
- 无人值守长周期自治任务。
- 商业化定价、外部开放注册和大规模市场推广。
## 8. 指标与评测
### 8.1 北极星指标
**每周被用户采纳的高价值任务数。**
“采纳”必须由用户明确标记为以下之一:直接使用、轻改使用、进入正式评审或产生被保留的工作区产物。只发送消息、完成一轮调用或模型自报共识都不算采纳。
### 8.2 当前阶段唯一关键指标
在第 12 周前,唯一关键指标是:
> **试点用户提交的目标任务中,被标记为“直接使用或轻改使用”的任务比例。方向性门槛为 60%。**
相对用户当前最佳单 Agent 的 20% 盲评质量或风险发现提升是配对实验的扩大决策门槛,不与采纳率拼成一个不可诊断的复合指标。两项当前基线均未知;小样本阶段只能作为方向性证据。
### 8.3 指标体系
| 类型 | 指标 | 试点目标 | 说明 |
| --- | --- | --- | --- |
| 激活 | 安装后 15 分钟内成功完成首个 Agent 回复 | >= 70% | 排除缺少 Agent/认证的非目标用户后计算 |
| 激活 | 7 天内完成首个多 Agent 目标任务 | >= 40% | 不是随意群聊 |
| 价值 | 直接使用或轻改使用率 | >= 60% | 用户任务级标记 |
| 价值 | 相对最佳单 Agent 的盲评提升 | >= 20% | 质量或风险发现至少一项 |
| 效率 | 从任务开始到可用结果的总人工时间 | 降低 >= 30% | 计入用户整合和返工,不只算模型延迟 |
| 质量 | 经人工验证的新增关键问题数 | 每个适用任务 >= 1 | 只算单 Agent 未发现且确实成立的问题 |
| 留存 | 目标用户第 4 周仍每周使用 | >= 35% | 样本量较小时同时报告人数 |
| 可靠性 | Agent 调用非用户原因失败率 | < 5% | 按 Agent/版本拆分 |
| 可靠性 | 停止命令在 5 秒内终止进程树 | >= 99% | 真机验证 |
| 安全 | 未授权工作区写入 | 0 | 一票否决 |
| 安全 | Provider/密钥/可执行路径泄露到渲染层或日志 | 0 | 一票否决 |
| 隐私 | 未告知的数据外发 | 0 | 一票否决 |
| 成本 | 每个被采纳任务的 Agent 调用数/耗时 | 持续下降 | 防止用无限轮数换质量 |
### 8.4 对照实验
每个优先场景先准备至少 10 个脱敏任务做方向性试点,采用配对交叉设计:
1. 为同一任务准备等价输入和统一交付要求,由同一用户分别使用当前最佳单 Agent 与 RoundRelay;顺序随机并做交叉平衡,避免学习效应固定偏向后一种工具。
2. 预先指定一个主要结果,例如总盲评分或关键风险发现率;其他维度作为次要结果,避免试验结束后挑最有利指标。
3. 至少两名领域评审独立盲评,使用固定 1-5 分量表评估正确性、完整性、关键风险、可执行性和证据质量,并记录拒用原因。
4. 报告配对差值、相对与绝对提升、样本量和 95% 置信区间;小样本可使用 bootstrap 区间,但不得仅凭点估计宣称“统计显著”。
5. 报告评审一致性:序数总分优先使用加权 Cohen's kappa,连续汇总分可使用 ICC;一致性不足时先校准量表,不进入扩大决策。
6. 同时记录总等待时间、用户操作时间、返工时间、调用次数和最终采纳。多 Agent 只有在质量收益覆盖时间与成本时才进入默认推荐。
每场景 10 个任务只用于发现方向、量表问题和明显失败,不足以单独证明普遍或显著优势。扩大试点前应根据观察方差补充配对样本,并报告不同用户和任务类型下是否方向一致。不要用“用户觉得很酷”“产生了更多观点”作为通过依据。
## 9. 架构、安全与隐私门槛
### 9.1 目标架构原则
继续保持单机 Electron 架构,但在代码职责上逐步形成五层:
```text
Vue 渲染层
-> 窄 preload API
-> Electron 主进程
|- 策略与授权层:数据去向、目录权限、预算、用户确认
|- 编排状态机:角色、轮次、停止、失败降级
|- 上下文与产物层:消息、文件引用、Skill、结果、审计
|- Agent 适配层:能力清单、参数、会话、取消、输出解析
`- 本地持久化与安全存储
-> 本机 Agent CLI / 用户批准的 Provider / 白名单安装源
```
近期不拆微服务。若 `LocalWorkspace` 同时承担持久化、提示构造、编排和运行状态的复杂度开始阻碍测试,再按上述边界拆分;不是为了“架构漂亮”提前重写。
### 9.2 隐私真相
产品必须在 UI 和文档中同时表达以下事实:
- 群组、消息和编排状态不上传到 RoundRelay 自有云端。
- Agent CLI 可能根据自身配置访问外部模型、更新服务或工具。
- 用户配置的 Provider 会收到被传入的提示、选中文件内容或工具结果。
- 本地 JSON 会话未做应用层加密,安全性依赖设备账号、磁盘加密和本地访问控制。
- Agent 进程拥有本地操作系统用户权限;RoundRelay 的只读参数不是完整 OS 沙箱。
任何“完全本地”“数据绝不出机”的宣传都必须经过运行模式限定。建议使用三种明确标签:
| 模式 | 条件 | 可用文案 |
| --- | --- | --- |
| 本地状态 | 会话和编排状态只保存在本机 | “RoundRelay 不上传会话状态” |
| 批准出网 | Agent 使用用户批准的外部 Provider | “内容将发送至:具体 Provider/地址” |
| 受控本地推理 | 所有目标 Agent 指向经批准的本地或内网模型,且网络检查通过 | “本次运行未配置公网模型出口” |
### 9.3 发布前安全门槛
| 门槛 | 要求 | 未通过时处理 |
| --- | --- | --- |
| IPC 与渲染层边界 | 新 API 采用固定方法、主帧校验、严格入参;不暴露路径、密钥和任意 shell | 不合入 |
| Agent 能力验证 | 每类 Agent 独立验证读取、写入、取消、会话、附件和错误输出 | 标记实验或禁用相关能力 |
| 写入可逆 | 写入前显示范围;运行后展示 diff/变化;高风险删除或覆盖需再次确认;有恢复方案 | 只读开放,写入关闭 |
| Provider 外发 | 运行前可见地址、模型、目标 Agent 和上下文类型;切换 Provider 要重新确认 | 禁止运行 |
| 安装供应链 | 固定版本,校验摘要/签名,下载大小和域名受限,安装日志脱敏 | 关闭一键安装,改为文档引导 |
| 数据生命周期 | 导出、删除、备份恢复、异常退出恢复有测试 | 限制为低敏试点 |
| 本地数据保护 | 明确设备磁盘加密基线;高敏场景决定是否需要应用层加密与自动锁定 | 不进入高敏场景 |
| 审计来源 | 本地记录 Agent、版本、模型、Provider、权限、协议、开始/结束/停止原因 | 不用于正式决策留痕 |
| 应用分发 | 内部认可的签名、完整性校验、回滚与版本标识 | 不扩大试点 |
| 隐私验证 | 空闲启动无意外连接;每种 Agent 模式建立预期出网清单 | 不宣称受控本地模式 |
### 9.4 高风险设计提醒
- **附件混合路由**:当前适配器仅 Codex、Hermes 和 OpenCode 支持图片,且数量上限不同。现有实现会在不等上下文时整轮拒绝;真机验证仍须确认上游版本不会静默忽略参数。
- **Skill 引用**:渲染层只应看到经清洗的标识和名称,主进程重新校验并解析;不能让前端提供任意本地路径。
- **提示词注入**:本地文件和 Skill 同样是不可信输入。系统提示、权限和停止条件不能由被读取内容覆盖。
- **共识伪装**:Agent 输出标记只能作为协议输入,不能直接等同事实正确或组织批准。
- **同源偏差**:多个 Agent 可能使用同一模型或 Provider。产品应显示实际多样性,避免虚假独立性。
- **本地状态损坏**:当前 JSON 适合 Alpha;长历史、异常退出、并发写入和迁移需要专门测试。只有出现真实性能或一致性瓶颈时才评估 SQLite。
## 10. 两院试点与内部 GTM
### 10.1 试点目标
内部 GTM 的目的不是获得下载量,而是回答三个问题:
1. 哪两类任务值得多 Agent?
2. RoundRelay 是否比用户自己切换多个 CLI 更省时间、更可靠?
3. 两院能否在不制造错误安全感的前提下接受它的数据与权限模型?
### 10.2 试点人群
建议招募 12-20 名设计伙伴,分三组:
| 组别 | 人数建议 | 条件 | 任务 |
| --- | --- | --- | --- |
| 工程组 | 5-8 | 每周使用本地代码 Agent | 代码/架构审查、故障假设 |
| 产品与研究运营组 | 4-6 | 经常写方案、PRD、评审材料 | 方案红队、需求澄清 |
| 研究组 | 3-6 | 有可脱敏研究设计或技术文档 | 方法审校、证据缺口 |
优先招募现有 CLI 用户,不从“不会安装或认证任何 Agent”的大众用户开始。前两周只使用公开或低敏脱敏任务,高敏任务必须等隐私门槛和院内政策确认。
### 10.3 六周试点节奏
| 周次 | 动作 | 输出 |
| --- | --- | --- |
| 第 0 周 | 访谈、现有流程观察、采集脱敏任务 | 问题证据、基线耗时、任务集 |
| 第 1 周 | 安装与首次任务陪跑 | 激活漏斗、兼容问题、认知误区 |
| 第 2 周 | 单聊与手动多 Agent 审校 | 失败日志、第一次采纳率 |
| 第 3-4 周 | 两个任务模板和对照实验 | 盲评、时间、成本、风险发现 |
| 第 5 周 | 结构化角色的低保真验证 | 是否需要更复杂编排 |
| 第 6 周 | 复盘与继续/收缩/停止决策 | 决策备忘录与下一阶段范围 |
### 10.4 渠道与运营
- 通过现有院内协作渠道招募和发布版本,不在 RoundRelay 内建设远程社交或通知系统。
- 每位试点用户有一个明确任务场景和一名产品联系人。
- 每周发布一页状态:任务数、采纳率、失败、隐私/权限事件、已知兼容问题、范围变更。
- 建立本地诊断包导出,默认脱敏,用户确认后才能分享给支持人员。
- 版本说明必须区分“已验证 Agent”“实验 Agent”“仅检测未验证 Agent”。
### 10.5 试点决策门
| 决策 | 条件 |
| --- | --- |
| 扩大试点 | 安全一票否决项为 0;目标任务采纳率点估计 >= 60%;至少一个场景相对单 Agent 的预设主要结果点估计提升 >= 20%;同时披露分子/分母、配对样本量、评审一致性和 95% 置信区间。小样本达标只允许有限扩样,不得宣称统计显著 |
| 保持小范围继续探索 | 用户价值有信号但效果不稳定;可明确归因于一个可修复瓶颈 |
| 收缩为统一 Agent 工作台 | 单聊有留存,但多 Agent 质量/效率没有优势 |
| 停止自治方向 | 多轮质量不升反降、成本/延迟明显失控,或用户始终偏好人工切换 |
| 停止试点 | 出现未授权写入、未告知外发、密钥泄露或无法恢复的数据损坏 |
## 11. 组织与资源
### 11.1 最小可行团队
| 角色 | 建议投入 | 责任 |
| --- | --- | --- |
| 产品负责人 | 0.5-1 人 | 用户研究、范围、试点、指标、决策日志 |
| Electron/桌面工程师 | 1 人 | IPC、安全存储、持久化、打包、系统集成 |
| Agent 集成/评测工程师 | 1 人 | CLI 适配、协议状态机、真机矩阵、评测集 |
| 前端/交互工程师 | 0.5-1 人 | 任务化 UI、结果/异议视图、可访问性 |
| 安全与 IT 评审 | 0.2-0.5 人 | 数据政策、供应链、签名分发、威胁评审 |
| 领域设计伙伴 | 每个场景 2-3 人 | 提供任务、盲评结果、解释错误成本 |
如果只有两名全职工程师,应先覆盖 macOS、Codex/Hermes/OpenCode 等少量高使用 Agent 和两个任务场景。不要同时承诺九类 Agent 真机支持、Windows、公证发布、复杂自治和高敏数据。
### 11.2 所有权
每个路线图项必须有一个 DRI。推荐所有权:
- PM:首要用户、任务集、指标、范围变更、试点决策。
- 桌面工程负责人:本地数据、IPC、权限、分发、异常恢复。
- Agent 工程负责人:能力清单、适配器、协议、评测、兼容降级。
- 安全评审人:外发口径、安装供应链、写入门槛、高敏数据准入。
- 场景负责人:确认输出是否真正可用,而不是只看模型自评。
### 11.3 工作节奏
- 每周一次兼容矩阵与安全问题评审。
- 每周一次试点数据复盘,先看任务结果,再看功能反馈。
- 每两周一次版本候选,未通过退出条件不扩大用户。
- 所有中途新增需求进入范围变更表,明确接受、延后或拒绝。
- 每四周复审战略假设,尤其是“多 Agent 是否有净收益”。
## 12. 风险与反例
| 风险/反例 | 为什么重要 | 早期信号 | 对策 |
| --- | --- | --- | --- |
| 多 Agent 放大同一个错误 | 多个角色可能共享模型、数据和偏差 | 答案措辞不同但事实错误一致 | 显示模型/Provider 同源性;引入证据审查;与单 Agent 基线对照 |
| 群聊比终端更慢 | 顺序调用延迟线性增加 | 用户等待后仍复制结果回原 CLI | 默认单 Agent;只在高价值任务推荐多 Agent;提供进度与预算 |
| 用户要的是产物,不是讨论 | 长对话难复用 | 用户频繁手工总结或复制到文档 | 把结论、异议、下一步和文件 diff 做成一等对象 |
| “本地”造成错误隐私预期 | Agent 仍可能把上下文发给外部 Provider | 用户无法说出数据去了哪里 | 运行前外发说明、Provider 标识、受控本地模式验证 |
| 上游 CLI 快速变化 | 参数、输出和认证随版本改变 | 升级后静默解析错误或只读失效 | 固定兼容版本、真机回归、能力降级、适配器契约测试 |
| 写入权限造成破坏 | CLI 以本机用户权限运行 | 只读模式仍有文件变化,或写入超出目录 | 默认只读、文件变化审计、禁用未验证写入、恢复与确认 |
| 自动共识不可信 | 模型可迎合提示或错误输出标记 | 快速全员同意但人类盲评低 | 共识不等于结束;使用异议/证据/人工裁决状态 |
| 一次性新鲜感 | 多 Agent 界面有吸引力但没有重复价值 | 首周高使用、第四周归零 | 只围绕重复任务优化;用周采纳任务数而非消息数 |
| 支持面过宽 | 九类 Agent、两系统、附件、Skill 同时推进 | 兼容问题占据大部分研发时间 | 根据真实使用率收缩支持矩阵;实验能力显式降级 |
| 本地 JSON 达到瓶颈 | 长历史、迁移和崩溃恢复未验证 | 启动变慢、文件膨胀、恢复失败 | 建性能阈值和迁移测试;达到门槛后再考虑 SQLite |
| 院内使用无法运维 | 无账号服务器也意味着远程诊断有限 | 每台机器问题无法复现 | 本地脱敏诊断包、版本/能力清单、可重放测试,不上传会话 |
| 远程多人诉求牵引产品漂移 | 两院协作天然会提出共享需求 | 团队开始建设账号、频道、通知 | 结果通过现有协作工具分享;是否做远程协作需独立战略评审 |
### 12.1 什么时候不该使用 RoundRelay
- 任务答案明确、失败成本低、最佳单 Agent 几分钟即可完成。
- 所有 Agent 实际指向同一模型,且没有独立证据或工具差异。
- 用户需要多人实时共编、审批和组织通知。
- 工作区包含不允许发送到当前 Provider 的敏感内容。
- 用户无法监督过程,却希望 Agent 直接执行不可逆操作。
把这些反例做进产品提示,比强行扩大使用率更能建立信任。
## 13. 预演失败(Pre-mortem)
假设 6 个月后 RoundRelay 试点失败,最可能出现以下情形:
| 失败情形 | 根因 | 预防动作 | 责任人 |
| --- | --- | --- | --- |
| 大家试了一次,没人每周回来 | 产品优化了群聊观感,没有嵌入真实重复任务 | 用 20 个真实任务先定义模板;周采纳任务数作为北极星 | PM |
| 多轮讨论很长,但结果不比单 Agent 好 | 角色只是提示词扮演,缺少独立信息和证据 | 建单 Agent 对照;没有净提升就停止复杂自治 | PM + 评测负责人 |
| 发生敏感内容意外出网 | 用户把“本地应用”理解为“模型不出网” | 运行前显示 Provider/地址/上下文;高敏模式只允许批准端点 | 安全负责人 |
| Agent 修改了不应修改的文件 | 过度依赖上游只读标志,缺少变化检测 | 未经真机验证的 Agent 禁止写入;运行后展示并可恢复 diff | 桌面负责人 |
| 上游升级后大量 Agent 失效 | 没有版本策略和兼容矩阵 | 固定已验证范围、自动契约测试、实验标识、快速禁用 | Agent 集成负责人 |
| 研发被 Windows、市场、同步和工作流编辑器拖散 | 路线图没有明确非目标和决策门 | 每次新增范围必须说明牺牲项;阶段 2 未达标不启动阶段 3/4 | 产品负责人 |
| 无法通过院内安全审查 | 供应链、数据生命周期、审计和签名后补 | 第 0 周引入安全评审;按发布门槛逐项收口 | 项目负责人 |
## 14. 版本退出标准
### 14.1 内部 Alpha 退出标准(进入 12-20 人试点)
- [ ] 目标 macOS 设备上至少 3 个高优先 Agent 完成检测、认证、调用、续聊、取消、只读及其声明的图片/Skill 能力真机验证。
- [ ] 未通过验证的 Agent/能力在 UI 中明确标为实验或默认隐藏。
- [ ] 运行前可见 Agent、Provider/出网、工作目录和读写权限。
- [ ] 手动多 Agent 与有界自动讨论均可中止,进程树在门槛内结束。
- [x] 图片与 Skill 已完成渲染层、窄 IPC、主进程校验、持久化/回收、目标路由和自动化测试闭环;真实 Agent 兼容性仍由前两项门槛控制。
- [x] 首次引导已完成检测门槛、键盘/返回关闭、滚动锁、双语和打包 UI 冒烟;真实首次成功率仍需试点数据验证。
- [ ] 工作区数据可导出、删除;异常退出恢复测试通过。
- [ ] 没有渲染层路径/密钥泄露,没有未授权写入。
- [ ] 内部分发包来源和版本可验证,安装回退方式已记录。
- [ ] 已完成 8-12 次访谈和 20 个脱敏基线任务。
### 14.2 封闭 Beta 退出标准(扩大院内使用)
- [ ] 目标任务“直接使用或轻改使用”率点估计 >= 60%,并报告分子/分母及 95% 置信区间。
- [ ] 至少一个场景相对最佳单 Agent 的预设主要结果点估计提升 >= 20%,配对样本、评审一致性和区间估计均已披露,且不同用户/任务子组没有明显反向信号。
- [ ] 第 4 周目标用户周留存 >= 35%,同时报告绝对人数。
- [ ] 非用户原因 Agent 调用失败率 < 5%,按 Agent/版本可追踪。
- [ ] 未告知外发、密钥泄露、未授权写入均为 0。
- [ ] 安装件版本与完整性可校验,干净设备安装验证通过。
- [ ] 关键运行来源和结果可本地导出,问题可由脱敏诊断包复现。
- [ ] 至少两个任务模板由对应领域负责人确认“比聊天记录更可用”。
### 14.3 1.0 退出标准(考虑更广分发)
- [ ] 连续两个版本周期达到 Beta 价值和安全门槛。
- [ ] macOS 正式签名、公证、升级和回滚完成;Windows 若宣称支持则有独立真机与 CI 证据。
- [ ] 数据分类、Provider 准入、日志/审计和本地数据保护通过院内评审。
- [ ] 上游 CLI 版本兼容政策、停用机制和维护责任明确。
- [ ] 结构化质询在至少一个场景证明净收益;否则 1.0 不包含自治卖点。
- [ ] 支持、故障响应、发布节奏和长期维护人员已确定。
- [ ] 所有公开表述经过事实核对,不把“本地状态”宣传成“任何数据绝不出机”。
## 15. 关键开放问题
以下问题必须在进入阶段 1 前明确负责人和截止日期:
| 问题 | 为什么必须回答 | 建议负责人 | 截止 |
| --- | --- | --- | --- |
| 首要用户到底是工程、产品还是研究人员? | 决定任务模板、评测量表和 Agent 支持优先级 | PM | 第 1 周 |
| 哪些数据级别允许进入哪些 Provider? | 决定试点边界和“受控本地模式” | 安全/数据负责人 | 第 2 周 |
| 首批正式验证哪 3-4 个 Agent? | 避免九类 Agent 平均用力 | PM + Agent 工程师 | 第 1 周 |
| “可用结果”由谁判定、如何盲评? | 防止用模型自评替代业务结果 | PM + 场景负责人 | 第 2 周 |
| 写入模式需要何种恢复保证? | 决定是否能进入真实代码和文档任务 | 桌面负责人 | 第 3 周 |
| 会话是否需要应用层加密? | 影响高敏数据准入、搜索和恢复设计 | 安全 + 架构负责人 | 第 3 周 |
| 中文工作名“群智接力”是否只用于内部? | 避免品牌与产品定位混乱 | 产品/品牌负责人 | 试点前 |
| Windows 是否是试点硬要求? | 若是,会显著改变资源和时间线 | 项目负责人 | 第 1 周 |
## 16. 接下来 10 个工作日
1. 冻结一个 Alpha 能力清单,逐项标记已验证、实验、开发中和不支持。
2. 选择首批 3-4 个 Agent 和两类目标任务,不再平均覆盖九类 Agent。
3. 完成 8-12 次用户访谈,收集 20 个脱敏真实任务及当前流程基线。
4. 在两台干净 macOS 设备执行检测、认证、首次调用、续聊、取消、只读和写入矩阵。
5. 在目标 macOS 上验收图片、Skill 与首次引导:验证 Codex/Hermes/OpenCode 图片参数、Hermes 原生 Skill、其他 Agent 定向 Skill 提示、干净用户首次检测,并把失败项降级为实验或禁用。
6. 设计运行前数据去向/权限确认和任务结果页的低保真原型,先做可用性验证。
7. 建立单 Agent 对照量表和试点评测表,不等功能完成后再补指标。
8. 与安全/IT 负责人确认 Provider、数据级别、安装源、签名和本地数据保护门槛。
9. 发布第一份每周状态模板,固定报告范围变化、风险、验证证据和下周决策。
10. 第 10 个工作日召开继续/收缩评审,只批准阶段 1 的必要范围。
## 附录 A:战略画布
| 要素 | RoundRelay 选择 |
| --- | --- |
| 愿景 | 让高价值知识工作在用户掌控数据与权限的前提下,获得多种 Agent 能力的独立审校与可执行接力 |
| 首要细分 | 已使用多个本地 Agent、处理非公开任务、需要对结果负责的两院知识工作者 |
| 成本定位 | 不追求最低调用成本,追求重要任务的风险降低和整合时间节省 |
| 价值主张 | 从手工切换与复制上下文,转为一个本地、可控、可追溯的任务工作台 |
| 关键取舍 | 当前 RoundRelay 不做云端群聊、远程参与者、无限自治、模型托管和泛化工作流平台;完整两院混合平台作为独立上层战略评审 |
| 北极星 | 每周被用户采纳的高价值任务数 |
| 增长方式 | 两院设计伙伴驱动,先深做两个任务场景,再扩 Agent 和用户 |
| 核心能力 | Electron 安全边界、Agent 适配、本地 Skill/附件上下文、结构化协议、评测、数据去向与权限治理 |
| 防御性 | 经验证的任务协议、脱敏评测集、兼容矩阵、政策适配和长期信任,而非 Agent 数量 |
## 附录 B:主要事实来源
- `README.md`:产品边界、支持 Agent、开发与发布状态。
- `desktop/README.md`:本地运行、Provider、安装和 Agent 支持矩阵。
- `documentation/architecture.md`:组件、信任边界、本地数据、网络面和已知风险。
- `documentation/permissions.md`:Renderer/Main/Agent 权限矩阵和限制。
- `documentation/flows.md`:启动、Provider、安装、会话、消息和自动讨论流程。
- `documentation/automation.md`:自动讨论边界、停止、失败与外部副作用。
- `documentation/tests.md`:已执行验证、自动化覆盖和明确测试缺口。
- `desktop/src/local-workspace.cjs`:单聊/群聊、持久化、上下文、会话引用、顺序调用和自动讨论实现。
- `desktop/src/cli-adapters.cjs`:Agent 调用参数、输出解析、会话与权限适配。
- `desktop/src/main.cjs` 与 `desktop/src/preload.cjs`:Electron 信任边界和对渲染层开放的能力。
- 当前工作区差异:图片附件、本地 Skill、首次引导、群组创建修复和 3D 品牌资产已完成实现与本轮可引用的自动化验证;打包 `.app` 另完成首启、群组创建、Skill 菜单、剪贴板图片预览、ASAR 资产/源码和退出清理冒烟。本文将其计为 Alpha 已实现能力,但不把自动化与打包冒烟替代真实 Agent 兼容矩阵、用户价值验证或公开发布证据。
-78
View File
@@ -1,78 +0,0 @@
# Architecture
## Product Boundary
Meldwork is a local Electron desktop product for direct and multi-Agent conversations. It has no account system, tenant model, application server, remote conversation service, database, email delivery, scheduled jobs, or public SEO surface.
The supported product surface is the packaged Electron application. The frontend is a renderer bundle; without the local preload bridge it shows a desktop-required state and cannot operate the workspace.
## Components
| Component | Responsibility | Primary evidence |
| --- | --- | --- |
| Vue renderer | Agent-first multi-session UI, direct-session rename/delete, per-Agent and per-round run status, background completion markers, first-run carousel, conversation/Skill/image composer UI, bounded attachment-preview cache, dismissible warnings, collapsed execution details, theme/i18n, user confirmation and input collection | `frontend/src/App.vue`, `frontend/src/composables/useAttachmentPreviews.js`, `frontend/src/desktop.js` |
| Electron preload | Exposes narrow workspace, installer/Skill, attachment, and Provider methods through `window.roundrelayDesktop` only to a local `file:` document | `desktop/src/preload.cjs` |
| Electron main | Trust boundary for IPC authorization and shutdown gating, persistence, attachment import/preview, Skill catalog access, process execution, navigation, sanitized run events, operating-system notifications, Provider storage, and installer control | `desktop/src/main.cjs` |
| Local workspace | Persists Meldwork conversations, safe message metadata, one main-only native session reference per conversation and Agent, legacy topic-reference migration, Agent-specific transcript deltas, Agent preferences/runtime evidence, and transient active/terminal run state | `desktop/src/local-workspace.cjs` |
| Attachment store | Imports validated local images into private app-owned storage, verifies metadata/checksums on read, and removes unreferenced entries | `desktop/src/attachment-store.cjs`, `desktop/src/image-dimensions.cjs` |
| Local Skill catalog | Scans bounded known roots per Agent and returns sanitized Skill coordinates without exposing paths or contents | `desktop/src/local-skill-catalog.cjs` |
| CLI adapters | Detect and invoke supported local Agent executables with per-Agent arguments and constrained child environments | `desktop/src/cli-adapters.cjs` |
| Agent installer | Detects Agents and runs allowlisted npm packages or official installer scripts after user confirmation | `desktop/src/agent-installer.cjs` |
| Provider store | Validates Provider metadata and encrypts the API key with Electron `safeStorage` | `desktop/src/provider-store.cjs` |
| Managed OpenClaw runtime | Creates isolated local configuration and a restricted tool policy for OpenClaw | `desktop/src/openclaw-runtime.cjs` |
## Trust Boundaries
1. The renderer is not trusted with Node.js, executable paths, native Agent session IDs or storage paths, attachment paths, Skill paths/contents, raw Provider credentials after saving, arbitrary filesystem reads, or arbitrary process execution. User-selected conversation workdirs are the only filesystem paths intentionally shown back to the renderer; pasted image bytes use one exact import payload.
2. Preload exposes only named IPC methods. Main validates that every request comes from the current main frame and exact bundled frontend file.
3. Main owns local files, OS dialogs, attachment validation/storage, Skill discovery/revalidation, Agent discovery, child processes, installer downloads, and credential encryption.
4. Agent CLIs are external processes. They receive a selected working directory, a constrained environment, permission flags, main-resolved image paths, and only their target-scoped Skill context after capability checks.
5. Provider and installer network destinations are external trust boundaries. Provider URLs are user supplied and validated; installer URLs/packages are code-defined allowlists.
## Local Data
| Data | Location | Protection |
| --- | --- | --- |
| Meldwork conversations, messages, native session-reference mappings, Agent preferences/runtime evidence | Electron user data: `roundrelay-workspace.json` | Local JSON written through main; native references are keyed by conversation and Agent, remain main-only, and snapshots remove executable paths |
| Imported image attachments | Electron user data under `attachments/` | App-owned copy; `0700` directories, `0600` files, magic/MIME/size/dimension validation, metadata checksum, ID-only renderer access |
| Provider metadata and encrypted key | Electron user data: `roundrelay-provider.json` | API key encrypted with `safeStorage`; file written atomically with mode `0600` |
| Managed OpenClaw state/config | Electron user data under `openclaw-managed/` | Per-scope directories, mode `0700`; config mode `0600`; API key passed by child environment |
| Theme, locale, and onboarding completion | Renderer `localStorage` | Non-secret UI preferences |
Active progress and terminal run events are transient and are not added to conversation messages. Persisted attachment references contain safe metadata only, and Skill references contain sanitized target/namespace/slug/name coordinates. Renaming a conversation preserves its native sessions; changing its working directory clears them to prevent cross-workspace resume. Deleting a Meldwork conversation removes its app-owned messages, native session-reference mapping, and attachments no longer referenced elsewhere, but does not call a CLI-specific deletion command or remove history stored by Codex, Hermes, or another Agent CLI.
There is no remote data store or application account namespace. Managed OpenClaw paths are isolated by local conversation and working-directory scope inside Electron's per-user data directory.
## Network Surface
- Agent CLIs may call their own configured model providers.
- Selected images and Skill-influenced prompts may be sent by an Agent CLI to its configured model provider after local validation.
- Compatible Agents may receive the user-configured Provider URL/model/key from main.
- The installer may download fixed Hermes/Kimi installer URLs or invoke npm for fixed package names.
- Credential-free HTTPS links may be opened in the operating-system browser; remote navigation is denied inside the Electron window.
- The renderer CSP uses `connect-src 'self'` and does not directly call model or application APIs.
## Known Risks And Assumptions
- Installer recipes use mutable upstream packages or scripts and currently do not verify a pinned digest or signature (`desktop/src/agent-installer.cjs`).
- Conversation history is local but not application-encrypted (`desktop/src/local-workspace.cjs`).
- Imported attachment copies are private and integrity-checked but not application-encrypted (`desktop/src/attachment-store.cjs`).
- Local Skill files are untrusted prompt input. Main prevents renderer-supplied paths and cross-Agent selection, but a selected Skill can still influence Agent behavior (`desktop/src/local-skill-catalog.cjs`, `desktop/src/local-workspace.cjs`).
- Native image support is currently limited to Codex, Hermes, and OpenCode, with different per-run limits; mixed groups fail before execution when they cannot receive equal image context (`desktop/src/cli-adapters.cjs`).
- Read-only enforcement depends partly on each upstream CLI honoring its permission flags; Meldwork adds per-CLI hardening where available (`desktop/src/cli-adapters.cjs`).
- Provider requests and native Agent authentication are external dependencies; Meldwork cannot guarantee their availability or data handling.
- macOS packaging uses ad-hoc signing for local validation, not a notarized public release (`desktop/scripts/after-pack.cjs`).
## Capability Absences
- No email sending, so there is no `emails.md`.
- No cron or scheduled background work, so there is no `cron.md`.
- No supported public or indexable routes, so there is no `seo.md`.
## Related Documents
- [Flows](flows.md)
- [Permissions](permissions.md)
- [Variables And Secrets](variables.md)
- [Test Coverage](tests.md)
- [Automation](automation.md)
-85
View File
@@ -1,85 +0,0 @@
# Automation And External Side Effects
Meldwork embeds local Agent automation and an Agent installer. There are no webhooks, server jobs, email automations, cron schedules, or unattended recurring tasks.
## Automation Inventory
| Automation | Trigger / owner | Automatic scope | External side effects |
| --- | --- | --- | --- |
| Manual Agent reply | Local user sends a message and selects targets | Invokes selected Agents sequentially once | Agent process, Provider/native network use, local message/session writes |
| Automatic discussion | Local user sends a group message in automatic mode | Runs every group Agent in complete rounds, 6 rounds by default and up to 10 or 30 minutes, and stops early on unanimous explicit consensus | Same as manual replies, repeated within the bound |
| Agent detection/readiness | App startup or explicit refresh | Scans fixed command names and credential evidence | Local process/file reads; Claude may run `auth status --json` |
| Agent installation | Local user confirms installation | One installer task at a time | Network download/npm access and global CLI installation |
| Managed OpenClaw setup | OpenClaw invocation with configured Provider | Writes isolated runtime configuration if changed | Local config directories/files; Provider call by OpenClaw |
| Run completion notification | A manual or automatic run reaches a terminal state while the app window is unfocused | Main emits one sanitized lifecycle event and one local operating-system notification | Local notification only; no network request or conversation message |
## Agent Invocation Contract
**Inputs Agents may read:**
- The current user message.
- Stable group-level user constraints plus a bounded group transcript. A resumed native session receives only messages after that Agent's previous final reply.
- Group name/topic and selected working directory.
- Main-resolved paths for selected root images when the target Agent's declared capability accepts the complete set. Automatic discussion keeps offering the root images to each Agent until that Agent first receives them successfully.
- Up to four main-revalidated Skill hints scoped to the selected target Agent. Hermes also receives the validated Skill slugs through its native `--skills` arguments.
- Only the selected Agent's native credentials and optional configured Provider values.
**Executable/tool surface:**
- Main may spawn only detected executables for Codex, Hermes, OpenClaw, WorkBuddy, Kimi Code, MiMo Code, Claude Code, Qwen Code, Gemini CLI, and OpenCode.
- Command shapes are code-defined in `desktop/src/cli-adapters.cjs`; prompt text does not select an arbitrary executable or shell command.
- Kimi read-only conversations use Agent Client Protocol `plan` mode. Write-authorized conversations retain Kimi's native stream-JSON prompt mode.
- OpenClaw receives an explicit tool allow/deny configuration. Execution, process, browser, gateway, cron, messaging, session spawning, and subagent tools are denied; write/edit/apply-patch are added only when the conversation permits writes.
- Other CLIs receive their supported plan/read-only or edit permission flags. These flags are hard guardrails outside the prompt, but enforcement ultimately depends on the upstream CLI.
**Steering:**
- `LocalWorkspace.promptFor` identifies the Agent, repeats bounded stable user constraints, adds only the group transcript delta needed by that Agent's conversation-scoped native session, and asks it not to impersonate other Agents.
- Automatic discussion preflights root-image limits for every Agent, so each participant can receive the same root image set or the run does not start. This equal-context guarantee applies to root-image availability, not identical prompts: Agents still run sequentially, so later participants can see earlier replies, while native sessions remain conversation-and-Agent-specific and Skill hints remain Agent-specific.
**Output contract:**
- CLI-specific parsers reduce output to final text, a main-only native session reference, bounded execution metadata, and completion state where the protocol exposes it.
- Output size and runtime are bounded in the adapter; secrets found in child errors are redacted before persistence.
- Before launching Hermes, main makes a best-effort read of the message-ID watermark through Electron's read-only SQLite API. After exit, a non-empty post-watermark `assistant` row with `finish_reason` equal to `stop` or `length` is authoritative. If the database is absent, locked, incompatible, or has no current-turn final row, Meldwork falls back to the ANSI-stripped official `--quiet` stdout instead of blocking the Agent. Any newly reported native session reference is persisted before this lookup. Final text becomes `message.content`; up to eight sanitized process steps and elapsed time remain separate metadata and are not included in later Agent prompts.
- Successful final text becomes a local Agent message. User-visible failure diagnostics remain local system messages; an all-failed manual run resolves after recording each failure because the user message has already been accepted and persisted.
- Active progress and terminal statuses are separate transient events. They are not appended to the durable message history, native session references are never exposed to the renderer, listener failures cannot change a completed run result, and shutdown suppresses terminal desktop notifications.
- Automatic replies carry an internal final-line consensus marker. Meldwork removes the marker before persistence and stops only when every Agent completes and agrees in the same round.
## Workspace Side Effects
- `allowWrite` defaults to false. Adapters with enforceable permission modes receive read-only/plan arguments until the user explicitly opts into writes.
- Hermes and OpenClaw do not expose an equivalent Meldwork-enforced read-only switch, so their capability label remains Agent-managed permissions regardless of `allowWrite`.
- Main passes write permission per invocation; it does not grant a renderer filesystem API.
- Agents run with the local OS user's process identity. Meldwork is not an OS-level sandbox.
## Installer Contract
**Approval:** The renderer requires confirmation before calling `agentInstaller.start`.
**Hard guardrails:**
- Fixed Agent catalog and OS-specific recipes.
- Fixed npm package names or HTTPS hosts.
- Validated npm executable/shim handling, especially on Windows.
- Sensitive ambient variables removed from the installer environment.
- One concurrent task, bounded download size, version verification after installation, and temporary-file cleanup.
**Known limitation:** Recipes currently use mutable upstream packages/scripts without digest or signature verification. Host/package allowlisting reduces arbitrary input risk but does not make upstream content immutable.
## Controls And Operations
| Control | Implementation |
| --- | --- |
| User approval | Send/start-auto/install actions originate from explicit UI commands; install uses confirmation |
| Rate/bounds | Manual targets run sequentially; auto discussion defaults to 6 complete rounds and is capped at 10 rounds and 30 minutes; one installer task at a time |
| Cancellation | Per-group stop aborts the process tree; installer cancel aborts download/process; quit waits for cleanup |
| Failure handling | Validation and submission errors are returned before persistence; execution failures after acceptance are stored as local diagnostic messages. Automatic runs isolate a failed Agent, continue the round, and retry it in later bounded rounds; terminal state is emitted separately |
| Audit trail | Conversation messages and bounded execution metadata are local; active/terminal events are transient and there is no separate immutable audit log |
| Kill switch | Stop the active group, cancel the installer, or quit the application |
## App-Owned Versus Agent-Owned Actions
- The app owns Meldwork conversation persistence, permission selection, process launch, run lifecycle events, operating-system notifications, installer selection, Provider encryption, and cancellation.
- Agents own generated text, their CLI-native session/history stores, and any work they perform through their CLI under the granted mode. Removing an app-owned session reference does not delete the Agent-owned native session.
- Agent suggestions do not directly mutate Meldwork configuration; configuration changes require renderer IPC handled by main.
-127
View File
@@ -1,127 +0,0 @@
# Security And Side-Effect Flows
Meldwork has one human actor: the local desktop user. There are no accounts, roles, claims, tenants, or server-side authorization checks. Protected operations are authorized by the Electron main process accepting IPC only from the exact bundled renderer main frame.
## 1. Application Startup
**Precondition:** The local user launches Meldwork.
1. Electron main creates `ProviderStore`, `AttachmentStore`, and `LocalWorkspace` under `app.getPath('userData')`, plus a `LocalSkillCatalog` rooted at `app.getPath('home')`.
2. Main registers IPC handlers, creates a sandboxed `BrowserWindow`, and loads the bundled frontend with `loadFile`.
3. Before accepting attachment work, main reconciles the private attachment directory against persisted message references. Malformed workspace state disables destructive cleanup.
4. Preload exposes workspace, installer, Skill, attachment, and Provider methods only when `location.protocol === 'file:'`.
5. The renderer requests a sanitized workspace snapshot and a non-probing Provider status.
6. The renderer performs the single startup Agent refresh, which detects installed CLIs and evaluates credential readiness. Main does not launch a duplicate eager scan.
7. On the first renderer launch, a three-slide onboarding carousel opens while that refresh runs. Its completion action remains disabled until detection actually settles; completion is stored only in renderer `localStorage`.
**Deny case:** IPC from a different frame, web contents instance, protocol, or file path fails `requireDesktopRenderer`.
**State/side effects:** Reads local Agent/Skill configuration and Meldwork data, removes only confirmed orphan attachment entries, and writes a non-secret onboarding preference after dismissal; it does not contact an application server.
## 2. Save Or Remove A Provider
**Precondition:** The user opens Provider settings in the local renderer.
1. Renderer collects `provider`, `baseUrl`, `model`, and `apiKey` and calls the preload method.
2. Main validates the renderer origin and passes only the four expected fields to `ProviderStore`.
3. `ProviderStore` accepts HTTPS or loopback HTTP URLs without embedded credentials, query, or fragment.
4. `safeStorage` encrypts the API key. Main atomically writes metadata plus ciphertext to `roundrelay-provider.json`.
5. Status responses return metadata and configuration state, never the API key.
6. Removing the Provider deletes the local credential file and refreshes Agent availability.
**Deny cases:** Missing recognized fields, invalid URL, unavailable OS encryption, encryption failure, or corrupt ciphertext fail closed. Main ignores unrecognized renderer properties instead of forwarding them to storage.
**External side effect:** Later Agent invocations may send prompts to the configured Provider. Saving metadata alone does not test or call the Provider.
## 3. Detect Or Install A Local Agent
**Precondition:** The user opens the Agent manager. Installation requires a second confirmation click in the renderer.
1. Main searches allowlisted command names in known user installation paths and verifies a non-empty version.
2. The renderer displays installed, ready, and installable states without executable paths.
3. On confirmed installation, main selects a code-defined recipe for the Agent and current OS.
4. npm recipes invoke a fixed package name. Hermes/Kimi recipes download from allowlisted HTTPS hosts into a temporary directory.
5. The installer runs one task at a time, filters sensitive environment variables, supports cancellation, and verifies the CLI version afterward.
6. Temporary installer files are removed and Agent detection is refreshed.
**Deny cases:** Unknown Agent, unsupported OS, already installed Agent, missing npm, non-allowlisted command/URL, invalid Windows npm shim, failed download, or failed version verification.
**External side effects:** Network download and global CLI installation. Current recipes are allowlisted but not cryptographically pinned.
## 4. Manage Meldwork Conversations
**Precondition:** At least one detected Agent is available. The user selects a working directory.
1. The sidebar groups direct conversations under each available Agent and may create more than one Meldwork conversation for the same Agent.
2. Renderer sends the Meldwork conversation name/topic, Agent kinds, user-selected directory, and `allowWrite`.
3. Main validates the renderer and delegates create, select, rename, update, or delete operations to `LocalWorkspace` using a Meldwork-owned conversation ID.
4. `LocalWorkspace` keeps only available Agent kinds, resolves the directory, stores `allowWrite` as an explicit boolean, and isolates native session-reference mappings by conversation and Agent.
5. Older topic-scoped references are migrated once: the current topic is preferred, otherwise the most recently active valid topic is selected, then the legacy keys are removed. Changing the working directory clears that conversation's native session mappings.
6. The workspace file is updated and a sanitized snapshot is emitted to the trusted renderer.
**Deny cases:** No available Agent, invalid group ID, or attempts to enable an unavailable Agent.
**State/side effects:** Writes local conversation configuration. Deleting a conversation removes its Meldwork messages and native session-reference mapping and is denied while that conversation is running. It does not delete any CLI-native session or history from the CLI's own storage.
## 5. Select Skills And Import Images
**Precondition:** A conversation and at least one target Agent are selected.
1. Typing `@` or pressing the Skill button asks `agentInstaller.skills(kind)` for each current target. The installed-Agent gate and `LocalSkillCatalog` return only sanitized `{targetKind, namespace, slug, name}` records.
2. The renderer keeps at most four selections and sends structured Skill coordinates, not filesystem paths or Skill contents.
3. The image button opens a main-owned PNG/JPEG picker. Clipboard images cross the bridge only as an exact `{name, mimeType, bytes}` payload.
4. `AttachmentStore` validates source-file safety, magic bytes, declared MIME/name agreement, the 8 MiB limit, private modes, checksum, and image dimensions before returning safe metadata plus a bounded preview.
5. Before persisting a message, `LocalWorkspace` resolves attachment IDs to main-only absolute paths, revalidates every Skill against the current target's catalog, and checks every selected Agent's image count/capability.
6. Persisted messages and renderer snapshots contain attachment metadata and target-scoped Skill hints only. Historical previews are loaded separately by ID through a bounded, visibility-driven renderer cache.
7. Removing an unsent image calls the discard IPC. Deleting a conversation removes only attachments no longer referenced by another message; startup cleanup removes confirmed orphans and interrupted-import residue.
**Deny cases:** Unknown/stale/cross-Agent Skill, more than four Skills, malformed attachment input, unsupported type, MIME mismatch, more than 8 MiB per image, more than four images, unsafe/tampered storage, unsupported target Agent, or a target-specific image limit. Validation fails before a message or Agent run is recorded.
**State/side effects:** Imports private local image copies and reads local Skill manifests. No Skill path, attachment path, preview payload, or raw bytes are persisted in the workspace snapshot.
## 6. Send A Message To Agents
**Precondition:** The conversation is not already running and at least one selected target belongs to the group.
1. `LocalWorkspace` persists the user message and creates a cancellable run.
2. It builds a bounded prompt from stable group-level user constraints and the recent group transcript. When the same native session is resumed, only messages after that Agent's previous final reply are added, together with Skill hints assigned to that Agent.
3. Main resolves the Agent executable and attachment paths internally, selects Provider/native credential environment, and invokes the CLI in the group working directory. Codex, Hermes, and OpenCode receive images through their validated native arguments; Hermes also receives selected Skill slugs through `--skills`.
4. CLI adapters apply per-Agent read-only or write-enabled arguments. Child processes receive an allowlisted system environment plus only current-Agent credentials.
5. Main emits sanitized active progress. Hermes process details are bounded and kept separate from the final answer; a pre-run message-ID watermark prevents an earlier turn from being selected as the current result.
6. Normalized final Agent text is stored as message content, while native session references remain main-only. For Hermes, a post-watermark non-empty `assistant` row with `finish_reason` `stop` or `length` overrides process output. When the watermark or final lookup is unavailable, locked, incompatible, or empty, the ANSI-stripped official `--quiet` stdout is used as the fallback; `tool_calls` rows are never selected. A newly reported Hermes session reference is persisted before this lookup, including when the database path cannot provide a result.
7. Failures are recorded as diagnostic system messages. One failed Agent does not cancel successful Agents; when every Agent fails after the user message has been persisted, the send resolves as an accepted failed run so the renderer does not restore and duplicate the committed draft or attachments.
8. On terminal state, main emits a best-effort sanitized run event. When the window is unfocused it also creates a content-free operating-system notification whose click action focuses the app and opens the Meldwork conversation; shutdown suppresses these notifications.
**Deny cases:** Empty text and no image, target outside the group, unavailable Agent, concurrent run, invalid Skill/attachment context, invalid sandbox, or authentication failure.
**External side effects:** Agent/provider network calls and possible workspace reads. Workspace writes are intended only when `allowWrite` is true, subject to upstream CLI enforcement.
## 7. Equal-Context Automatic Discussion And Stop
**Precondition:** A group has at least two Agents and a persisted user topic root.
1. Before creating a run, `LocalWorkspace` checks the root message's entire image set against every group Agent. If any Agent cannot receive the same set, the discussion is rejected before any process starts.
2. The user chooses a bounded round count; the default is six and the hard cap is ten.
3. `LocalWorkspace` invokes every group Agent once per complete round, preserving one main-only native session reference for each conversation and Agent. The topic root remains a visual and run-lifecycle boundary, not a native-session boundary. Root images are delivered once to each Agent; a failed delivery is retried on that Agent's next attempt instead of silently dropping context. Root Skill selections are revalidated and remain scoped to their target Agent on every attempt.
4. Each Agent must end with one internal consensus marker. The marker is removed before persistence, and the run stops early only when every Agent completes and agrees in the same round.
5. A failed Agent is recorded once per stable failure, while later Agents and later bounded rounds continue. Active progress and terminal state are emitted separately from message history.
6. The user can stop the group, and a 30-minute total runtime limit also aborts the active process tree.
**Deny cases:** Missing topic root, fewer than two Agents, or another active run.
**State/side effects:** Final messages, bounded execution metadata, and main-only session-reference mappings are updated. Active and terminal run events remain transient. There is a per-run safety timer, but no recurring schedule, unattended cron trigger, or retry daemon.
“Equal context” here means every Agent can receive the same root image set before the run is allowed. Discussion remains sequential, so later Agents in a round can see earlier replies; native sessions remain conversation-and-Agent-specific, while selected Skills remain Agent-specific.
## 8. Shutdown IPC Gate
**Precondition:** Electron receives `before-quit`.
1. Main sets `shutdownStarted` before beginning asynchronous cleanup.
2. Every registered trusted IPC handler continues to verify the renderer and then rejects with `DESKTOP_CLIENT_SHUTTING_DOWN`; no new read, mutation, import, install, Provider, or Agent operation can enter during teardown.
3. Main cancels pending/active installation work, aborts all Agent runs, and waits for both workspace and installer settlement.
4. Repeated quit requests reuse the same cleanup operation. After settlement, main marks cleanup complete and calls `app.quit()` once more to exit.
**Deny case:** Any renderer IPC request after shutdown starts is rejected even when it comes from the otherwise trusted main frame.
**State/side effects:** Active child processes are stopped before exit, and no new IPC side effect can race with final persistence or attachment cleanup.
-56
View File
@@ -1,56 +0,0 @@
# Meldwork Launch Kit
This document contains draft public-relations and repository-launch copy. Do not publish the open-source claims until a license is committed.
## Positioning
**Category:** Open-source, local-first multi-Agent collaboration workspace
**Tagline:** Put your local agents to work, together.
**One sentence:** Meldwork brings the AI coding Agent CLIs already on your computer into one persistent workspace, where they can contribute, challenge, and continue the same task while orchestration stays local.
**Chinese:** Meldwork 是开源、本地优先的多智能体协作工作台,让不同 Agent 围绕同一任务共同产出。
## Launch Headline
Introducing Meldwork: multi-Agent collaboration on your machine
After a license is added, use:
Introducing Meldwork: open-source multi-Agent collaboration on your machine
## Short Announcement
Meldwork brings the AI coding agents already on your computer into one shared task. Let Codex, Claude Code, Kimi Code, MiMo Code, Gemini CLI, OpenCode, Qwen Code, and other local CLIs contribute, challenge, and continue each other's work while orchestration stays on your machine.
## Chinese Announcement
Meldwork 把电脑中已经安装的编码 Agent CLI 汇聚到同一个任务中。Codex、Claude Code、Kimi Code、MiMo Code 等 Agent 可以在持续会话里提出方案、交叉审查并继续彼此的工作,群组、消息与编排仍保留在本机。
## Story Structure
1. Developers already use several capable Agent CLIs, but their context and sessions are scattered.
2. Meldwork gives those existing Agents one persistent local workspace.
3. A real demo should show one task moving from proposal to challenge, revision, and finished output.
4. The product difference is heterogeneous CLIs, native-session continuity, local orchestration, and explicit permission boundaries.
5. Local-first does not mean every model is offline; selected Providers may still use the network.
6. The open-source call to action should ask for Agent adapters, installation compatibility reports, Skills, and real-device validation.
## Pull Request Summary
### What changed
- Rebranded the user-facing product from RoundRelay to Meldwork.
- Introduced an asymmetric open-braid identity with separate transparent navigation, app-icon, and favicon assets.
- Replaced the industrial onboarding imagery and aligned the bilingual onboarding copy with local multi-Agent collaboration.
- Added MiMo Code CLI support in read-only plan mode and moved Qwen Code to the end of the Agent list.
- Refined the conversation workspace without changing the narrow Electron preload boundary or local storage compatibility identifiers.
### Why
The new name and visual system make the product category clearer, reduce the Mac/MAC ambiguity in the earlier naming direction, and better express multiple independent Agents combining their work without implying a closed or symmetric system.
### Verification
List the exact frontend tests, desktop tests, builds, package validation, Electron screenshots, and real CLI checks executed for the final change.
-70
View File
@@ -1,70 +0,0 @@
# Permissions
## Identity Model
Meldwork has no login, account, role, claim, tenant, server session, or database authorization layer. The local OS user who launches the application is the only human actor.
Authorization is process-bound:
- Electron main is the trusted enforcement boundary.
- The renderer is an untrusted UI client.
- Preload exposes a fixed IPC surface only to the local `file:` document.
- Every privileged IPC handler verifies the current main frame and exact bundled frontend path.
## Resource Matrix
| Resource / operation | Local user through trusted renderer | Other document/frame | Agent child process | Enforcement |
| --- | --- | --- | --- | --- |
| Read sanitized workspace snapshot | Allowed | Denied | No IPC access | `requireDesktopRenderer`; `LocalWorkspace.snapshot` removes executable paths |
| Create/update/delete Meldwork conversations and messages | Allowed | Denied | No direct state-file API | Named IPC handlers plus `LocalWorkspace` validation; renderer cannot address CLI-native sessions |
| Receive active/terminal run state | Sanitized events only | Denied | N/A | Main allowlists conversation IDs, Agent kinds, status, topic ID, and timestamps; no native session IDs, internal paths, credentials, or message text |
| Create operating-system notifications | Not directly allowed | Denied | N/A | Main creates notifications only from trusted terminal run events; renderer cannot supply notification content |
| Select a working directory | Allowed through OS dialog | Denied | Receives selected path only at invocation | Main-owned `dialog.showOpenDialog` |
| List/select local Skills | Sanitized coordinates and display names for installed target Agents | Denied | Receives only its validated selections in prompt/native arguments | Installed-Agent gate; main-owned `LocalSkillCatalog`; per-target revalidation; no renderer-supplied path |
| Import/preview/discard images | Allowed through picker, exact paste payload, attachment ID, and discard list | Denied | Receives a main-resolved path only for an authorized invocation | Main-owned dialog and `AttachmentStore`; type/size/dimension/checksum validation; referenced files cannot be discarded |
| Enable workspace writes | Explicit per-conversation opt-in | Denied | Enforced adapters receive write mode only when enabled; Hermes/OpenClaw remain Agent-managed | Stored `allowWrite`; per-CLI permission flags where supported |
| Detect Agents | Allowed | Denied | N/A | Code-defined command catalog; executable paths stay in main |
| Install an Agent | Explicit confirmation; one active task | Denied | Installer child runs fixed recipe | Allowlisted Agent, command, package/URL, and OS checks |
| Read Provider status | Metadata only | Denied | N/A | `ProviderStore.status` excludes API key |
| Save/remove Provider key | Allowed through Provider form | Denied | Compatible Agent receives scoped environment at invocation | Exact four-field payload, URL validation, `safeStorage` |
| Open external URL | Credential-free HTTPS in OS browser | In-window navigation denied | N/A | `isAllowedExternalUrl`; `setWindowOpenHandler`; `will-navigate` |
| Camera, microphone, Bluetooth, webview, generic Electron permissions | Denied | Denied | N/A | BrowserWindow settings and permission handlers |
## Renderer Boundary
`desktop/src/preload.cjs` exposes only:
- `localWorkspace`: get/refresh/create/update/delete/send/start/stop/directory selection plus sanitized change, run-finished, and notification-open events.
- `agentInstaller`: catalog/Skill listing/state/start/cancel/sidebar visibility/change events. Skill listing accepts one Agent kind and returns sanitized records only.
- `localAttachments`: `pickImages`, exact-payload `importImage`, ID-only `preview`, and ID-list `discard`. It exposes no filesystem read, path resolution, or arbitrary file picker method.
- `localAgentProvider`: non-probing status, explicit encryption probe, save, and delete.
Node integration, webviews, insecure content, and renderer permission requests are disabled. The packaged Electron fuses disable `runAsNode`, Node option injection, CLI inspect arguments, and loading app code outside `app.asar`.
## Filesystem Permissions
- Meldwork state is written only by main.
- Provider and managed OpenClaw secret-bearing files use atomic writes and mode `0600`; their directories use mode `0700` where created by those modules.
- Imported PNG/JPEG copies live under the Electron user-data attachment directory. The root/entry directories use mode `0700`, files use `0600`, metadata carries a SHA-256 checksum, and symlink/path escape or tampering fails closed.
- Conversation data is local JSON and is not application-encrypted.
- Agent executable paths, native session IDs, native session storage paths, attachment paths, Skill paths, preview payloads, and credential material are stripped from renderer snapshots and lifecycle events. User-selected conversation workdirs are intentionally returned for display and editing.
- Main scans known per-Agent Skill roots and reads only bounded Skill manifest prefixes for catalog metadata. The renderer cannot request an arbitrary directory or file.
- Agent filesystem access is scoped to the selected working directory by Meldwork arguments/configuration where the upstream CLI supports it.
## Process And Network Permissions
- Only supported Agent kinds can be invoked.
- Skill selections are limited to four, must still exist in the selected Agent's current catalog, and are routed only to that Agent. Hermes receives validated slugs through its native `--skills` flags; no renderer value becomes an arbitrary CLI argument.
- Image references are limited to four and are resolved inside main. Codex and OpenCode accept up to four; Hermes accepts one; unsupported or unequal multi-Agent context is rejected before any child process starts.
- Child environments begin with a system-variable allowlist, then add only the selected Agent's native credentials and optional configured Provider values.
- Installer environments remove variables whose names look like keys, tokens, secrets, passwords, credentials, cookies, authorization, or prompts.
- Provider URLs require HTTPS, except loopback HTTP for local development.
- Installer scripts require HTTPS and an allowlisted host; npm packages are selected from a fixed map.
- Once `before-quit` starts, every trusted IPC handler rejects new work with `DESKTOP_CLIENT_SHUTTING_DOWN` until active runs and installer work have settled.
## Explicit Limitations
- There is no per-user permission separation inside the app because there is no account model.
- An Agent process runs with the operating-system permissions of the local user. Meldwork permission flags reduce capability but are not an OS sandbox.
- Imported images are private local copies but are not application-encrypted; when sent, their contents may reach the selected Agent's configured model Provider.
- Installer approval authorizes an external system modification. Package/script integrity is not currently pinned.
-85
View File
@@ -1,85 +0,0 @@
# Verification And Test Coverage
Verification evidence below spans 2026-07-29 through 2026-07-30 on macOS arm64. Repository tooling and CI support Node.js 20.19 or newer; the packaged desktop runs on Electron's bundled Node.js runtime. The current working-tree unit suites, builds, and package validation were last run on 2026-07-30; unchanged distribution and audit rows retain the prior executed baseline.
## Executed Verification
| Check | Evidence | Result |
| --- | --- | --- |
| Frontend unit suite | `npm --prefix frontend test` | 6 files, 54/54 tests passed |
| Desktop unit suite | `npm --prefix desktop test` | 219/219 tests passed |
| CI Node adapter compatibility | `npx -y [email protected] --test desktop/test/cli-adapters.test.cjs` | 71/71 tests passed |
| Renderer builds | `npm --prefix frontend run build` and `npm --prefix frontend run build:desktop` | Both Vite builds passed |
| Current desktop package validation | `npm --prefix desktop run pack` and `codesign --verify --deep --strict desktop/dist/mac-arm64/Meldwork.app` | Electron 39.8.5 arm64 app directory built, fuses applied, and strict ad-hoc signature verification passed; no valid Developer ID identity was available |
| Electron Hermes SQLite smoke | Temporary Hermes-shaped `state.db` queried through Electron with `ELECTRON_RUN_AS_NODE=1` | Electron 39.8.5 / Node.js 22.22.1 loaded `node:sqlite`; the watermark excluded the previous turn and `tool_calls`, a post-watermark `length` result was accepted, and a later `stop` result took precedence |
| macOS distribution | `npm --prefix desktop run dist` | arm64 `.app`, DMG, and ZIP produced |
| Production dependency audit | `npm --prefix desktop audit --omit=dev` and `npm --prefix frontend audit --omit=dev` | 0 known vulnerabilities in both production dependency sets |
| ACP runtime packaging | Inspect and extract packaged `app.asar`, then dynamically import its production modules | `@agentclientprotocol/sdk` 0.23.0, `zod` 4.4.3, the ACP client, and both inbound Zod validators loaded successfully |
| Focused packaged-app smoke | Launch the packaged `.app` with a fresh temporary user-data directory and attach through local CDP | First-run carousel loaded with focus, inert background, no horizontal overflow, and an enabled `Start using` state; the redesigned group modal created a real temporary group without error; `@` loaded local Skills; a synthetic clipboard image crossed preload/IPC and rendered a 64x64 preview; normal application quit left no Meldwork process or debug listener |
| Logo packaging | Compare source, Vite output, and packaged asar hashes; inspect generated `icon.icns` and packaged UI | The final 3D Relay Fold app/product PNGs match source byte-for-byte in the package, and the packaged renderer loads the 1024x1024 brand asset |
| Idle startup network | Inspect the Meldwork process tree with `lsof` after disconnecting CDP | No established TCP or UDP connection; only the explicitly enabled loopback CDP listener remained |
| Empty-store secure-storage behavior | Unit tests plus a one-second process sample | No `SecItemCopyMatching`, `SecKeychain`, `CSSM_Decrypt`, or decrypt call observed without a stored Provider |
| macOS signature | `codesign --verify --deep --strict` | Passed with ad-hoc signature; `spctl` rejection is expected until Developer ID signing/notarization exists |
The packaged smoke exercises the first-run UI, group creation, Skill IPC, and clipboard attachment IPC. It does not execute a real Agent CLI, and later source changes still require a fresh package smoke before release.
## Automated Coverage
| Use case / rule | Expected behavior, including deny case | Evidence | Status |
| --- | --- | --- | --- |
| Renderer workbench and onboarding | Shows the configured Agent catalog, gates the first-run carousel on Agent detection, supports focus/scroll/back dismissal, local groups/direct chats, theme, and locale | `frontend/src/__tests__/roundrelay/App.spec.js` | Passed locally |
| Conversation run UX and session management | Shows Agent logos and capabilities, multiple direct sessions, rename/delete controls, distinct running animation, per-Agent queued/running/completed state, automatic current/max round progress, active-topic expansion, exact warning dismissal, collapsed process details, background direct completion markers, unavailable-Agent history access, and truthful permission/image limits | `frontend/src/__tests__/roundrelay/App.spec.js`; `desktop/test/main-security.test.cjs` | Passed locally |
| Composer Skill routing | Loads Skills only for selected targets, supports `@` selection, and sends structured target-scoped coordinates | `frontend/src/__tests__/roundrelay/App.spec.js`; `desktop/test/local-skill-catalog.test.cjs`; `desktop/test/agent-installer.test.cjs`; `desktop/test/local-workspace.test.cjs` | Passed locally |
| Image attachment UI | Imports picker/paste images, permits image-only messages, blocks send and concurrent paste batches during import, keeps accepted all-failed messages out of the composer, discards unsent/overflow/stale imports, and sends metadata without paths or preview data | `frontend/src/__tests__/roundrelay/App.spec.js` | Passed locally |
| Attachment preview cache | Loads visible previews only, limits concurrent main-process requests to two, and evicts non-visible least-recent entries from the bounded cache | `frontend/src/__tests__/roundrelay/attachmentPreviews.spec.js` | Passed locally |
| Renderer bridge normalization | Uses only `window.roundrelayDesktop` services and normalizes missing data | `frontend/src/__tests__/roundrelay/desktop.spec.js` | Passed locally |
| Renderer i18n | Chinese/English keys and interpolation remain consistent | `frontend/src/__tests__/roundrelay/i18n.spec.js` | Passed locally |
| Renderer CSP and assets | Entry point keeps self-only/default restrictions, denies unsafe surfaces, and uses relative packaged assets | `frontend/src/__tests__/roundrelay/security.spec.js` | Passed locally |
| Main-frame IPC authorization | All registered local channels accept only the exact frontend main frame; other frames/paths are denied before dispatch | `desktop/test/main-security.test.cjs` | Passed locally |
| Electron navigation, lifecycle, and shutdown gate | Loads bundled frontend, denies in-window remote navigation, serializes refresh, rejects every trusted IPC call after shutdown starts, then waits for workspace/installer cleanup before quitting | `desktop/test/main-security.test.cjs` | Passed locally |
| Narrow preload API | Local document gets only named workspace/installer/Skill/attachment/Provider methods; attachment APIs expose no file read or path resolution; non-local documents get no privileged API | `desktop/test/preload-security.test.cjs` | Passed locally |
| Packaged Electron hardening | Fuses disable Node/runtime injection and require ASAR integrity | `desktop/test/package-security.test.cjs` | Passed locally |
| Provider secret storage | Complete payload required; URL constrained; key encrypted; failures/corruption fail closed; delete works | `desktop/test/provider-store.test.cjs` | Passed locally |
| Agent readiness | Detects native credentials without returning values and scopes forwarded credential variables by Agent | `desktop/test/local-agent-readiness.test.cjs` | Passed locally |
| Installer allowlist and lifecycle | Fixed recipes, URL/command rejection, environment filtering, verification, cancellation, and single-task behavior | `desktop/test/agent-installer.test.cjs` | Passed locally |
| Image storage and validation | Validates PNG/JPEG signatures, MIME/name agreement, 8 MiB/four-image limits, image dimensions, private storage, checksums, tamper resistance, cleanup, bounded previews, and referenced-attachment deletion guards | `desktop/test/attachment-store.test.cjs`; `desktop/test/image-dimensions.test.cjs`; `desktop/test/main-security.test.cjs` | Passed locally |
| CLI invocation and Hermes final-result safety | Per-Agent read-only/write arguments, Kimi ACP lifecycle, image limits, Hermes native Skill preload, best-effort pre-run message watermark, post-watermark `assistant` `stop`/`length` selection, ANSI-stripped official stdout fallback, early session persistence, environment scoping, secret redaction, and process-tree cancellation | `desktop/test/cli-adapters.test.cjs` | Passed locally |
| Workspace state and automation | Explicit persisted-field allowlists, path stripping, visual topic roots, conversation-and-Agent session reuse, legacy topic-session migration, workdir session invalidation, Agent-specific transcript deltas, target-scoped Skills, image-only messages, equal-image-context preflight, attachment retry delivery, final-only downstream relay, early session persistence, failure isolation, complete rounds, strict consensus, and runtime bounds | `desktop/test/local-workspace.test.cjs` | Passed locally |
| Terminal run lifecycle | Completion events are transient and best-effort, listener failures do not change results, all-failed automatic runs keep the round-limit diagnostic, notification payloads are content-free, notification clicks open the target conversation, background successful direct runs receive completion markers, and shutdown suppresses new notifications | `desktop/test/local-workspace.test.cjs`; `desktop/test/main-security.test.cjs` | Passed locally |
| Managed OpenClaw | Isolated paths, key absent from config, immutable permission config, invalid scopes fail closed | `desktop/test/openclaw-runtime.test.cjs` | Passed locally |
| macOS packaging transform | Removes unused permission declarations, sets Electron fuses, and applies ad-hoc signing | `desktop/test/after-pack.test.cjs` | Passed locally |
## CI-Declared Checks
`.github/workflows/ci.yml` declares these push and pull-request checks:
- Frontend: `npm ci`, `npm test`, `npm run build`, `npm run build:desktop` on Ubuntu.
- Desktop: frontend/desktop `npm ci`, `npm test`, and `npm run pack` on macOS.
The workflow file exists, but branch protection and successful required checks are external GitHub state and are not verified by this repository documentation.
## Proposed Tests
| Type | Use case / rule | Expected behavior | Status |
| --- | --- | --- | --- |
| Automated packaged-app IPC integration | Extend the focused packaged smoke to exercise real preload IPC and attachment/Skill flows | Privileged calls work only in the main frame; imported images and selected Skills reach the expected Agent without exposing paths | Proposed |
| Guarded live | Real Agent CLI matrix on supported macOS/Windows versions | Detection, readiness, prompt execution, cancellation, session resume, Hermes final-row timing, and permission behavior match unit contracts | Proposed |
| Guarded live | Installer recipes on clean disposable OS users/VMs | Only the selected Agent is installed, cancellation cleans up, secrets are absent from child environment | Proposed |
| Automated integration | Installer artifact integrity after pinning is implemented | Modified script/package metadata is rejected before execution | Proposed |
| Automated visual regression | Light/dark theme and logo contrast | Detect theme, layout, or asset regressions without relying on manual screenshot inspection | Proposed |
| Guarded live | macOS Developer ID signing and notarization | Gatekeeper accepts the app and DMG on a clean machine | Proposed |
| Guarded live | Windows packaged build | Installation, app launch, CLI discovery, npm shim handling, and cancellation work on a real Windows device | Proposed |
| Automated resilience | Abrupt termination during workspace/provider writes | Previous valid state remains readable and no secret is left in a temporary file | Proposed |
## Gaps
| Priority | Rule with no current verification | Exposure | Status |
| --- | --- | --- | --- |
| High | Mutable installer packages/scripts are not digest- or signature-pinned | Upstream compromise can execute code as the local user | None until integrity pinning exists |
| High | Public release signing/notarization is not exercised | Distributed macOS artifacts can be rejected by Gatekeeper | None |
| Medium | No packaged preload/Agent end-to-end suite beyond the focused UI smoke | Unit mocks may miss real IPC, attachment/Skill routing, or Agent process integration faults | Focused packaged UI smoke only |
| Medium | No clean-machine live Agent/installer matrix | Upstream CLI changes can invalidate flags, output parsing, paths, or installer behavior | None |
| Medium | No live Hermes CLI end-to-end or real macOS notification-click exercise | Upstream Hermes persistence timing and operating-system notification behavior can diverge from unit mocks | Unit coverage plus Electron SQLite smoke only |
| Medium | Windows release is not built or tested in CI | Windows support remains implementation-level rather than release-verified | None |
| Low | No automated visual regression or accessibility audit | Theme, contrast, focus, and responsive regressions require manual discovery | None |
| Low | No long-history/performance test | Large local message histories may expose latency or file-size limits late | None |
-60
View File
@@ -1,60 +0,0 @@
# Variables And Secrets
Meldwork requires no `.env` file, server URL, account token, JWT, database credential, email credential, or cron secret.
## User And Runtime Configuration
| Name | Used by | Source and scope | Persistence / rotation | Risk and handling |
| --- | --- | --- | --- | --- |
| `ROUNDRELAY_CODEX_SANDBOX` | Codex adapter | Optional Electron process environment | Read at invocation; restart to change | Accepts only `read-only` or `workspace-write`; invalid values fall back to `read-only` |
| Provider `provider` | Compatible local Agents | User input in renderer, validated by main | Stored locally in `roundrelay-provider.json`; replace through UI | Non-secret display metadata |
| Provider `baseUrl` | Compatible local Agents | User input in renderer, validated by main | Stored locally; replace through UI | HTTPS required, except loopback HTTP; embedded credentials/query/fragment rejected |
| Provider `model` | Compatible local Agents | User input in renderer, validated by main | Stored locally; replace through UI | Non-secret model identifier |
| Provider `apiKey` | Compatible local Agents | User enters it in renderer; passed once over local IPC | Encrypted with `safeStorage`; rotate by saving a replacement or delete through UI | Secret; status and snapshots never return it |
| Theme / locale | Renderer | User preference | Browser `localStorage` | Non-secret |
## Native Agent Credential Inputs
Meldwork detects and forwards only credential variables associated with the selected Agent:
| Agent | Recognized process variables |
| --- | --- |
| Codex | `OPENAI_API_KEY` |
| Hermes | `OPENAI_API_KEY`, `OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY` |
| OpenClaw | `OPENAI_API_KEY`, `OPENROUTER_API_KEY` |
| WorkBuddy | `OPENAI_API_KEY`, `ANTHROPIC_API_KEY` |
| Kimi Code | `MOONSHOT_API_KEY`, `KIMI_API_KEY` |
| Claude Code | `ANTHROPIC_API_KEY` |
| Qwen Code | `DASHSCOPE_API_KEY`, `OPENAI_API_KEY` |
| Gemini CLI | `GEMINI_API_KEY`, `GOOGLE_API_KEY` |
| OpenCode | `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENROUTER_API_KEY` |
These credentials are owned and rotated through the upstream CLI/provider, not Meldwork. Readiness checks report only `ready`, `missing`, or `unknown`; they do not return credential values.
## Internal Child-Process Variables
Main derives these at invocation time; users should not set them as Meldwork configuration:
| Variables | Purpose |
| --- | --- |
| `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_MODEL` | Normalized configured Provider values for compatible Agents |
| `HERMES_INFERENCE_MODEL` | Mirrors the configured model for Hermes |
| `CODEBUDDY_MODEL`, `CODEBUDDY_API_KEY`, `CODEBUDDY_BASE_URL` | WorkBuddy Provider mapping |
| `ROUNDRELAY_OPENCLAW_API_KEY` | Secret environment reference used by generated OpenClaw config |
| `OPENCLAW_HOME`, `OPENCLAW_STATE_DIR`, `OPENCLAW_CONFIG_PATH`, `OPENCLAW_WORKSPACE_DIR` | Isolated managed OpenClaw runtime paths |
| `HERMES_EXEC_ASK`, `HERMES_YOLO_MODE`, `OPENCODE_PERMISSION` | Runtime safety overrides |
The API key is intentionally absent from generated OpenClaw JSON and supplied only through the child environment.
## Non-Secret System Environment
Agent children receive an allowlist of operating-system identity/path/locale/temp/certificate variables plus a constructed `PATH`. Meldwork does not forward the entire Electron environment.
## Pre-Release Checklist
- Confirm no real keys or tokens are present in source, tests, logs, or packaged assets.
- Confirm `safeStorage.isEncryptionAvailable()` succeeds on each supported release OS.
- Confirm Provider status never returns the API key and credential files remain mode `0600`.
- Confirm child-process environment tests cover newly supported Agents or variables.
- Confirm installer logs/state never expose command output or ambient secrets.
- Rotate any credential used during guarded live testing before distributing artifacts.
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; worker-src 'self'; object-src 'none'; frame-src 'none'; base-uri 'none'; form-action 'none'"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: meldwork-media:; media-src 'self' meldwork-media:; font-src 'self' data:; connect-src 'self'; worker-src 'self'; object-src 'none'; frame-src 'none'; base-uri 'none'; form-action 'none'"
/>
<meta name="theme-color" content="#f6f3ed" />
<title>Meldwork</title>
+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" fill="none" role="img" aria-labelledby="title">
<title id="title">Meldwork app icon</title>
<rect x="4" y="4" width="120" height="120" rx="24" fill="#0E0F11" />
<g transform="translate(16 16) scale(1.5)">
<path
d="M8 50V29.5C8 20.8 12.5 15 19.5 15C26.2 15 30 20.5 30 29V49C30 31.5 34.7 20.5 41 20.5C47.7 20.5 51.5 26 51.5 34.5V39.5C51.5 45.2 53.7 48.6 58 50"
stroke="#FFFFFF"
stroke-width="8.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="58" cy="50" r="4.25" fill="#EF5A45" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 620 B

@@ -0,0 +1,49 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" fill="none" role="img" aria-labelledby="title desc">
<title id="title">Meldwork Open Stitch brand board</title>
<desc id="desc">A five-panel preview of the Meldwork V2 mark, wordmark, app icon, construction, color system, and slogan.</desc>
<rect width="1600" height="1000" fill="#E9EBEE" />
<rect x="60" y="60" width="700" height="410" rx="8" fill="#FFFFFF" />
<g transform="translate(170 160) scale(3.15)">
<path d="M8 50V29.5C8 20.8 12.5 15 19.5 15C26.2 15 30 20.5 30 29V49C30 31.5 34.7 20.5 41 20.5C47.7 20.5 51.5 26 51.5 34.5V39.5C51.5 45.2 53.7 48.6 58 50" stroke="#15171A" stroke-width="8.5" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="58" cy="50" r="4.25" fill="#EF5A45" />
</g>
<text x="400" y="298" fill="#15171A" font-family="Inter, SF Pro Display, Segoe UI, Arial, sans-serif" font-size="58" font-weight="650">Meldwork</text>
<text x="402" y="342" fill="#6E7278" font-family="Inter, SF Pro Text, Segoe UI, Arial, sans-serif" font-size="18" font-weight="600">OPEN STITCH / V2 CANDIDATE</text>
<rect x="800" y="60" width="740" height="410" rx="8" fill="#0E0F11" />
<g transform="translate(880 162) scale(3.15)">
<path d="M8 50V29.5C8 20.8 12.5 15 19.5 15C26.2 15 30 20.5 30 29V49C30 31.5 34.7 20.5 41 20.5C47.7 20.5 51.5 26 51.5 34.5V39.5C51.5 45.2 53.7 48.6 58 50" stroke="#FFFFFF" stroke-width="8.5" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="58" cy="50" r="4.25" fill="#EF5A45" />
</g>
<text x="1115" y="278" fill="#FFFFFF" font-family="Inter, SF Pro Display, Segoe UI, Arial, sans-serif" font-size="38" font-weight="650">Different agents.</text>
<text x="1115" y="334" fill="#FFFFFF" font-family="Inter, SF Pro Display, Segoe UI, Arial, sans-serif" font-size="38" font-weight="650">Work you can verify.</text>
<rect x="60" y="510" width="450" height="430" rx="8" fill="#F7F8F9" />
<rect x="178" y="600" width="214" height="214" rx="42" fill="#0E0F11" />
<g transform="translate(205 627) scale(2.5)">
<path d="M8 50V29.5C8 20.8 12.5 15 19.5 15C26.2 15 30 20.5 30 29V49C30 31.5 34.7 20.5 41 20.5C47.7 20.5 51.5 26 51.5 34.5V39.5C51.5 45.2 53.7 48.6 58 50" stroke="#FFFFFF" stroke-width="8.5" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="58" cy="50" r="4.25" fill="#EF5A45" />
</g>
<text x="285" y="865" text-anchor="middle" fill="#15171A" font-family="Inter, SF Pro Text, Segoe UI, Arial, sans-serif" font-size="20" font-weight="600">APP ICON</text>
<rect x="550" y="510" width="450" height="430" rx="8" fill="#FFFFFF" />
<line x1="625" y1="725" x2="925" y2="725" stroke="#D6D9DD" stroke-width="2" stroke-dasharray="7 9" />
<line x1="775" y1="585" x2="775" y2="855" stroke="#D6D9DD" stroke-width="2" stroke-dasharray="7 9" />
<g transform="translate(653 618) scale(2.45)">
<path d="M8 50V29.5C8 20.8 12.5 15 19.5 15C26.2 15 30 20.5 30 29V49C30 31.5 34.7 20.5 41 20.5C47.7 20.5 51.5 26 51.5 34.5V39.5C51.5 45.2 53.7 48.6 58 50" stroke="#15171A" stroke-width="8.5" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="58" cy="50" r="4.25" fill="#EF5A45" />
</g>
<circle cx="795" cy="740" r="14" fill="#EF5A45" />
<text x="775" y="865" text-anchor="middle" fill="#15171A" font-family="Inter, SF Pro Text, Segoe UI, Arial, sans-serif" font-size="20" font-weight="600">ASYMMETRIC / OPEN / CONTINUOUS</text>
<rect x="1040" y="510" width="500" height="430" rx="8" fill="#15171A" />
<circle cx="1140" cy="650" r="52" fill="#FFFFFF" />
<circle cx="1290" cy="650" r="52" fill="#EF5A45" />
<circle cx="1440" cy="650" r="52" fill="#0E0F11" stroke="#3B3E43" stroke-width="2" />
<text x="1140" y="735" text-anchor="middle" fill="#FFFFFF" font-family="Inter, SF Pro Text, Segoe UI, Arial, sans-serif" font-size="18" font-weight="600">PAPER</text>
<text x="1290" y="735" text-anchor="middle" fill="#FFFFFF" font-family="Inter, SF Pro Text, Segoe UI, Arial, sans-serif" font-size="18" font-weight="600">CORAL</text>
<text x="1440" y="735" text-anchor="middle" fill="#FFFFFF" font-family="Inter, SF Pro Text, Segoe UI, Arial, sans-serif" font-size="18" font-weight="600">CARBON</text>
<text x="1290" y="835" text-anchor="middle" fill="#FFFFFF" font-family="Inter, SF Pro Display, Segoe UI, Arial, sans-serif" font-size="34" font-weight="650">Evidence before acceptance.</text>
<text x="1290" y="875" text-anchor="middle" fill="#AEB2B8" font-family="Inter, SF Pro Text, Segoe UI, Arial, sans-serif" font-size="21" font-weight="500">The endpoint marks verification, not consensus.</text>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="3 10 60 45" fill="none" role="img" aria-labelledby="title">
<title id="title">Meldwork Open Stitch mark for dark backgrounds</title>
<path
d="M8 50V29.5C8 20.8 12.5 15 19.5 15C26.2 15 30 20.5 30 29V49C30 31.5 34.7 20.5 41 20.5C47.7 20.5 51.5 26 51.5 34.5V39.5C51.5 45.2 53.7 48.6 58 50"
stroke="#FFFFFF"
stroke-width="8.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="58" cy="50" r="4.25" fill="#EF5A45" />
</svg>

After

Width:  |  Height:  |  Size: 508 B

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="3 10 60 45" fill="none" role="img" aria-labelledby="title">
<title id="title">Meldwork Open Stitch mark</title>
<path
d="M8 50V29.5C8 20.8 12.5 15 19.5 15C26.2 15 30 20.5 30 29V49C30 31.5 34.7 20.5 41 20.5C47.7 20.5 51.5 26 51.5 34.5V39.5C51.5 45.2 53.7 48.6 58 50"
stroke="#15171A"
stroke-width="8.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="58" cy="50" r="4.25" fill="#EF5A45" />
</svg>

After

Width:  |  Height:  |  Size: 487 B

@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 10 236 45" fill="none" role="img" aria-labelledby="title">
<title id="title">Meldwork wordmark for dark backgrounds</title>
<path
d="M8 50V29.5C8 20.8 12.5 15 19.5 15C26.2 15 30 20.5 30 29V49C30 31.5 34.7 20.5 41 20.5C47.7 20.5 51.5 26 51.5 34.5V39.5C51.5 45.2 53.7 48.6 58 50"
stroke="#FFFFFF"
stroke-width="8.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="58" cy="50" r="4.25" fill="#EF5A45" />
<g transform="translate(2 0)">
<path
fill="#FFFFFF"
d="M80.27 43L80.27 20.09L87.19 20.09L91.34 35.72L95.45 20.09L102.39 20.09L102.39 43L98.09 43L98.09 24.97L93.55 43L89.09 43L84.56 24.97L84.56 43ZM116.56 37.72L120.94 38.45Q120.09 40.86 118.27 42.12Q116.45 43.38 113.72 43.38Q109.39 43.38 107.31 40.55Q105.67 38.28 105.67 34.83Q105.67 30.7 107.83 28.37Q109.98 26.03 113.28 26.03Q116.98 26.03 119.13 28.48Q121.27 30.92 121.17 35.97L110.17 35.97Q110.22 37.92 111.23 39.01Q112.25 40.09 113.77 40.09Q114.8 40.09 115.5 39.53Q116.2 38.97 116.56 37.72ZM116.81 33.28Q116.77 31.38 115.83 30.38Q114.89 29.39 113.55 29.39Q112.11 29.39 111.17 30.44Q110.23 31.48 110.25 33.28ZM124.75 43L124.75 20.09L129.14 20.09L129.14 43ZM148.86 43L144.78 43L144.78 40.56Q143.77 41.98 142.38 42.68Q141 43.38 139.59 43.38Q136.73 43.38 134.7 41.07Q132.66 38.77 132.66 34.64Q132.66 30.42 134.64 28.23Q136.63 26.03 139.66 26.03Q142.44 26.03 144.47 28.34L144.47 20.09L148.86 20.09ZM137.14 34.34Q137.14 37 137.88 38.19Q138.94 39.91 140.84 39.91Q142.36 39.91 143.42 38.62Q144.48 37.33 144.48 34.77Q144.48 31.91 143.45 30.65Q142.42 29.39 140.81 29.39Q139.25 29.39 138.2 30.63Q137.14 31.88 137.14 34.34ZM156.28 43L151.03 26.41L155.3 26.41L158.41 37.28L161.27 26.41L165.5 26.41L168.27 37.28L171.44 26.41L175.77 26.41L170.44 43L166.22 43L163.36 32.33L160.55 43ZM177.06 34.47Q177.06 32.28 178.14 30.23Q179.22 28.19 181.2 27.11Q183.17 26.03 185.61 26.03Q189.38 26.03 191.78 28.48Q194.19 30.92 194.19 34.66Q194.19 38.42 191.76 40.9Q189.33 43.38 185.64 43.38Q183.36 43.38 181.29 42.34Q179.22 41.31 178.14 39.32Q177.06 37.33 177.06 34.47ZM181.56 34.7Q181.56 37.17 182.73 38.48Q183.91 39.8 185.63 39.8Q187.34 39.8 188.51 38.48Q189.67 37.17 189.67 34.67Q189.67 32.23 188.51 30.92Q187.34 29.61 185.63 29.61Q183.91 29.61 182.73 30.92Q181.56 32.23 181.56 34.7ZM201.83 43L197.44 43L197.44 26.41L201.52 26.41L201.52 28.77Q202.56 27.09 203.4 26.56Q204.23 26.03 205.3 26.03Q206.8 26.03 208.19 26.86L206.83 30.69Q205.72 29.97 204.77 29.97Q203.84 29.97 203.2 30.48Q202.56 30.98 202.2 32.31Q201.83 33.64 201.83 37.88ZM209.92 43L209.92 20.09L214.31 20.09L214.31 32.25L219.45 26.41L224.86 26.41L219.19 32.47L225.27 43L220.53 43L216.36 35.55L214.31 37.69L214.31 43Z"
/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 10 236 45" fill="none" role="img" aria-labelledby="title">
<title id="title">Meldwork wordmark</title>
<path
d="M8 50V29.5C8 20.8 12.5 15 19.5 15C26.2 15 30 20.5 30 29V49C30 31.5 34.7 20.5 41 20.5C47.7 20.5 51.5 26 51.5 34.5V39.5C51.5 45.2 53.7 48.6 58 50"
stroke="#15171A"
stroke-width="8.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="58" cy="50" r="4.25" fill="#EF5A45" />
<g transform="translate(2 0)">
<path
fill="#15171A"
d="M80.27 43L80.27 20.09L87.19 20.09L91.34 35.72L95.45 20.09L102.39 20.09L102.39 43L98.09 43L98.09 24.97L93.55 43L89.09 43L84.56 24.97L84.56 43ZM116.56 37.72L120.94 38.45Q120.09 40.86 118.27 42.12Q116.45 43.38 113.72 43.38Q109.39 43.38 107.31 40.55Q105.67 38.28 105.67 34.83Q105.67 30.7 107.83 28.37Q109.98 26.03 113.28 26.03Q116.98 26.03 119.13 28.48Q121.27 30.92 121.17 35.97L110.17 35.97Q110.22 37.92 111.23 39.01Q112.25 40.09 113.77 40.09Q114.8 40.09 115.5 39.53Q116.2 38.97 116.56 37.72ZM116.81 33.28Q116.77 31.38 115.83 30.38Q114.89 29.39 113.55 29.39Q112.11 29.39 111.17 30.44Q110.23 31.48 110.25 33.28ZM124.75 43L124.75 20.09L129.14 20.09L129.14 43ZM148.86 43L144.78 43L144.78 40.56Q143.77 41.98 142.38 42.68Q141 43.38 139.59 43.38Q136.73 43.38 134.7 41.07Q132.66 38.77 132.66 34.64Q132.66 30.42 134.64 28.23Q136.63 26.03 139.66 26.03Q142.44 26.03 144.47 28.34L144.47 20.09L148.86 20.09ZM137.14 34.34Q137.14 37 137.88 38.19Q138.94 39.91 140.84 39.91Q142.36 39.91 143.42 38.62Q144.48 37.33 144.48 34.77Q144.48 31.91 143.45 30.65Q142.42 29.39 140.81 29.39Q139.25 29.39 138.2 30.63Q137.14 31.88 137.14 34.34ZM156.28 43L151.03 26.41L155.3 26.41L158.41 37.28L161.27 26.41L165.5 26.41L168.27 37.28L171.44 26.41L175.77 26.41L170.44 43L166.22 43L163.36 32.33L160.55 43ZM177.06 34.47Q177.06 32.28 178.14 30.23Q179.22 28.19 181.2 27.11Q183.17 26.03 185.61 26.03Q189.38 26.03 191.78 28.48Q194.19 30.92 194.19 34.66Q194.19 38.42 191.76 40.9Q189.33 43.38 185.64 43.38Q183.36 43.38 181.29 42.34Q179.22 41.31 178.14 39.32Q177.06 37.33 177.06 34.47ZM181.56 34.7Q181.56 37.17 182.73 38.48Q183.91 39.8 185.63 39.8Q187.34 39.8 188.51 38.48Q189.67 37.17 189.67 34.67Q189.67 32.23 188.51 30.92Q187.34 29.61 185.63 29.61Q183.91 29.61 182.73 30.92Q181.56 32.23 181.56 34.7ZM201.83 43L197.44 43L197.44 26.41L201.52 26.41L201.52 28.77Q202.56 27.09 203.4 26.56Q204.23 26.03 205.3 26.03Q206.8 26.03 208.19 26.86L206.83 30.69Q205.72 29.97 204.77 29.97Q203.84 29.97 203.2 30.48Q202.56 30.98 202.2 32.31Q201.83 33.64 201.83 37.88ZM209.92 43L209.92 20.09L214.31 20.09L214.31 32.25L219.45 26.41L224.86 26.41L219.19 32.47L225.27 43L220.53 43L216.36 35.55L214.31 37.69L214.31 43Z"
/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

+751 -325
View File
File diff suppressed because it is too large. Load diff
+460 -41
View File
@@ -8,6 +8,7 @@ import { setLocale } from '../../i18n.js'
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView
const originalClipboard = navigator.clipboard
const originalExecCommand = document.execCommand
function baseSnapshot() {
return {
@@ -105,22 +106,24 @@ function createBridge() {
onChanged: vi.fn(() => vi.fn()),
}
const localAgentProvider = {
status: vi.fn(async () => ({
status: vi.fn(async kind => ({
kind,
provider: '',
baseUrl: '',
model: '',
configured: false,
encryptionAvailable: true,
})),
probe: vi.fn(async () => ({
probe: vi.fn(async kind => ({
kind,
provider: '',
baseUrl: '',
model: '',
configured: false,
encryptionAvailable: true,
})),
save: vi.fn(async input => ({ ...input, configured: true, encryptionAvailable: true })),
delete: vi.fn(async () => ({ configured: false, encryptionAvailable: true })),
save: vi.fn(async (kind, input) => ({ kind, ...input, configured: true, encryptionAvailable: true })),
delete: vi.fn(async kind => ({ kind, configured: false, encryptionAvailable: true })),
}
const localAttachments = {
pickImages: vi.fn(async () => ({ attachments: [] })),
@@ -183,6 +186,8 @@ afterEach(() => {
if (originalScrollIntoView) HTMLElement.prototype.scrollIntoView = originalScrollIntoView
else delete HTMLElement.prototype.scrollIntoView
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: originalClipboard })
if (originalExecCommand) Object.defineProperty(document, 'execCommand', { configurable: true, value: originalExecCommand })
else delete document.execCommand
vi.restoreAllMocks()
})
@@ -268,29 +273,46 @@ describe('RoundRelay workbench', () => {
wrapper.unmount()
})
it('uses non-probing Provider status at boot and probes only when the panel opens', async () => {
it('defers Provider reads until the settings Provider page opens', async () => {
const { wrapper, bridge } = await mountApp()
expect(bridge.localAgentProvider.status).toHaveBeenCalledTimes(1)
expect(bridge.localAgentProvider.status).not.toHaveBeenCalled()
expect(bridge.localAgentProvider.probe).not.toHaveBeenCalled()
await wrapper.findAll('.sidebar-footer button')[1].trigger('click')
await wrapper.get('.sidebar-settings-entry').trigger('click')
expect(wrapper.get('.system-settings-page').exists()).toBe(true)
expect(bridge.localAgentProvider.status).not.toHaveBeenCalled()
await wrapper.findAll('.settings-tabs button')[1].trigger('click')
await flushPromises()
expect(bridge.localAgentProvider.status).toHaveBeenCalledTimes(4)
expect(bridge.localAgentProvider.probe).toHaveBeenCalledTimes(1)
expect(bridge.localAgentProvider.probe).toHaveBeenCalledWith('hermes')
wrapper.unmount()
})
it('shows an existing Provider as configured after boot', async () => {
it('shows an existing Agent-specific Provider in settings', async () => {
const { wrapper } = await mountApp(({ bridge }) => {
bridge.localAgentProvider.status.mockResolvedValueOnce({
provider: 'Local gateway',
baseUrl: 'https://gateway.example/v1',
model: 'roundrelay-model',
configured: true,
encryptionAvailable: true,
})
bridge.localAgentProvider.status.mockImplementation(async kind => (
kind === 'hermes'
? {
provider: 'Local gateway',
baseUrl: 'https://gateway.example/v1',
model: 'roundrelay-model',
configured: true,
encryptionAvailable: true,
}
: { provider: '', baseUrl: '', model: '', configured: false, encryptionAvailable: true }
))
bridge.localAgentProvider.probe.mockImplementation(bridge.localAgentProvider.status)
})
expect(wrapper.find('.sidebar-footer .footer-status.ready').exists()).toBe(true)
await wrapper.get('.sidebar-settings-entry').trigger('click')
await wrapper.findAll('.settings-tabs button')[1].trigger('click')
await flushPromises()
expect(wrapper.find('.provider-agent-list svg.ready').exists()).toBe(true)
expect(wrapper.get('.provider-editor-header h2').text()).toContain('Hermes')
expect(wrapper.get('.provider-status').text()).toContain('Configured')
wrapper.unmount()
})
@@ -300,7 +322,7 @@ describe('RoundRelay workbench', () => {
expect(wrapper.findAll('.agent-card')).toHaveLength(AGENTS.length)
expect(wrapper.get('.home-header h1').text()).toBe('Local Agents')
const controls = wrapper.findAll('.workspace-preferences button')
const controls = wrapper.findAll('.sidebar-footer-actions button')
await controls[0].trigger('click')
expect(wrapper.get('.home-header h1').text()).toBe('本地 Agent')
@@ -313,7 +335,7 @@ describe('RoundRelay workbench', () => {
const { wrapper } = await mountApp()
expect(wrapper.find('.brand-actions').exists()).toBe(false)
expect(wrapper.findAll('.workspace-preferences button')).toHaveLength(2)
expect(wrapper.findAll('.sidebar-footer-actions button')).toHaveLength(2)
expect(wrapper.findAll('.sidebar-agent-main img')).toHaveLength(2)
await wrapper.get('.sidebar-toggle').trigger('click')
@@ -334,6 +356,26 @@ describe('RoundRelay workbench', () => {
expect(source).toMatch(/\.sidebar\.collapsed \.sidebar-toggle\s*\{[^}]*flex:\s*0 0 34px;/s)
})
it('anchors collapsed sidebar controls while the workspace width animates', () => {
const source = readFileSync(resolve(process.cwd(), 'src/styles.css'), 'utf8')
expect(source).toMatch(/\.app-shell\s*\{[^}]*transition:\s*grid-template-columns 0\.22s cubic-bezier\(0\.16, 1, 0\.3, 1\);/s)
expect(source).toMatch(/\.sidebar\.collapsed \.brand-row\s*\{[^}]*width:\s*var\(--sidebar-collapsed-width\);[^}]*align-self:\s*flex-start;/s)
expect(source).toMatch(/\.sidebar\.collapsed \.conversation-nav\s*\{[^}]*width:\s*var\(--sidebar-collapsed-width\);[^}]*align-self:\s*flex-start;/s)
expect(source).toMatch(/\.sidebar\.collapsed \.sidebar-footer\s*\{[^}]*width:\s*var\(--sidebar-collapsed-width\);[^}]*align-self:\s*flex-start;/s)
expect(source).toMatch(/\.sidebar\.collapsed \.sidebar-footer button\s*\{[^}]*width:\s*34px;/s)
expect(source).toMatch(/\.sidebar\.collapsed \.new-group-button\s*\{[^}]*margin:\s*2px 15px 12px;/s)
expect(source).not.toMatch(/\.sidebar\.collapsed \.new-group-button\s*\{[^}]*margin-inline:\s*auto;/s)
})
it('uses one settings entry and a borderless Agent mention menu', () => {
const source = readFileSync(resolve(process.cwd(), 'src/styles.css'), 'utf8')
expect(source).toMatch(/\.sidebar-footer\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\) auto;/s)
expect(source).toMatch(/\.skill-menu\s*\{[^}]*border:\s*0;/s)
expect(source).toMatch(/\.skill-option\.agent-mention-option small\s*\{[^}]*-webkit-line-clamp:\s*2;/s)
})
it('keeps the collapsed mobile sidebar to the expandable brand row', () => {
const source = readFileSync(resolve(process.cwd(), 'src/styles.css'), 'utf8')
const mobileRules = source.slice(source.indexOf('@media (max-width: 760px)'))
@@ -342,20 +384,48 @@ describe('RoundRelay workbench', () => {
expect(mobileRules).toMatch(/\.sidebar\.collapsed\s*>\s*:not\(\.brand-row\)\s*\{[^}]*display:\s*none;/)
})
it('lets the conversation and composer grow with wide desktop windows', () => {
const source = readFileSync(resolve(process.cwd(), 'src/styles.css'), 'utf8')
expect(source).toMatch(/--conversation-content-width:\s*clamp\(820px,\s*70vw,\s*1360px\)/)
expect(source).toMatch(/\.message-list\s*\{[^}]*width:\s*min\(var\(--conversation-content-width\),\s*100%\);[^}]*justify-self:\s*center;/s)
expect(source).toMatch(/\.composer-shell\s*\{[^}]*width:\s*min\(var\(--conversation-content-width\),\s*100%\);/s)
})
it('pins the turn rail beside the sidebar with Dock proximity magnification', () => {
const source = readFileSync(resolve(process.cwd(), 'src/styles.css'), 'utf8')
expect(source).toMatch(/\.message-stage\s*\{[^}]*width:\s*100%;[^}]*grid-template-columns:\s*48px minmax\(0,\s*1fr\);[^}]*padding-right:\s*48px;/s)
expect(source).toMatch(/\.turn-rail\s*\{[^}]*top:\s*50%;[^}]*transform:\s*translateY\(-50%\);/s)
expect(source).toContain('.turn-rail button:has(+ button:hover)')
expect(source).toContain('.turn-rail button:has(+ button + button + button:hover)')
expect(source).toMatch(/\.turn-rail button:hover > span:first-child,[^{]+\{[^}]*--turn-offset:\s*10px;[^}]*--turn-scale:\s*2\.4;/s)
expect(source).toMatch(/\.turn-rail button > span:first-child\s*\{[^}]*transform-origin:\s*left center;/s)
expect(source).not.toContain('.turn-rail::before')
expect(source).toMatch(/\.message-row\.topic-reply\s*\{[^}]*border-left:\s*0;/s)
expect(source).toMatch(/\.message-row\.group-message\.agent \.message-body::before\s*\{[^}]*background:\s*var\(--agent-accent\);/s)
expect(source).toMatch(/\.message-copy-surface\s*\{[^}]*cursor:\s*text;/s)
expect(source).toMatch(/\.run-status-panel\.group\s*\{[^}]*border-left:\s*0;/s)
expect(source).toMatch(/\.composer-box\s*\{[^}]*border:\s*0;/s)
expect(source).toMatch(/\.composer-context-row\s*\{[^}]*border-bottom:\s*0;/s)
expect(source).toMatch(/\.send-button,\s*\.stop-button\s*\{[^}]*border:\s*0;/s)
})
it('saves the exact Provider payload exposed by preload', async () => {
const { wrapper, bridge } = await mountApp()
await wrapper.findAll('.sidebar-footer button')[1].trigger('click')
await wrapper.get('.sidebar-settings-entry').trigger('click')
await wrapper.findAll('.settings-tabs button')[1].trigger('click')
await flushPromises()
const inputs = wrapper.findAll('.form-stack input')
const inputs = wrapper.findAll('.provider-editor input')
await inputs[0].setValue('Local gateway')
await inputs[1].setValue('https://gateway.example/v1')
await inputs[2].setValue('roundrelay-model')
await inputs[3].setValue('secret-key')
await wrapper.get('form.form-stack').trigger('submit')
await wrapper.get('form.provider-editor').trigger('submit')
await flushPromises()
expect(bridge.localAgentProvider.save).toHaveBeenCalledWith({
expect(bridge.localAgentProvider.save).toHaveBeenCalledWith('hermes', {
provider: 'Local gateway',
baseUrl: 'https://gateway.example/v1',
model: 'roundrelay-model',
@@ -394,7 +464,7 @@ describe('RoundRelay workbench', () => {
await wrapper.get('.conversation-link').trigger('click')
expect(wrapper.get('.system-message').text()).toContain('Hermes failed: Agent execution stopped.')
await wrapper.findAll('.workspace-preferences button')[0].trigger('click')
await wrapper.findAll('.sidebar-footer-actions button')[0].trigger('click')
expect(wrapper.get('.conversation-link').text()).toContain('Agent 群聊')
expect(wrapper.get('.system-message').text()).toContain('Hermes 调用失败:Agent 执行已停止。')
wrapper.unmount()
@@ -471,17 +541,26 @@ describe('RoundRelay workbench', () => {
await wrapper.get('.conversation-link').trigger('click')
expect(wrapper.get('.mode-segmented [data-mode="auto"]').classes()).toContain('active')
expect(wrapper.get('.round-stepper output').text()).toBe('6 rounds')
expect(wrapper.get('.round-settings-trigger').text()).toContain('6 rounds')
expect(wrapper.findAll('.target-chip').every(chip => chip.classes().includes('selected'))).toBe(true)
expect(wrapper.findAll('.target-chip').every(chip => chip.attributes('disabled') !== undefined)).toBe(true)
expect(wrapper.findAll('.target-chip')[0].attributes('aria-label')).toBe('Codex participates in automatic discussion')
for (let index = 0; index < 7; index += 1) await wrapper.get('.round-stepper-decrease').trigger('click')
expect(wrapper.get('.round-stepper output').text()).toBe('1 rounds')
for (let index = 0; index < 11; index += 1) await wrapper.get('.round-stepper-increase').trigger('click')
expect(wrapper.get('.round-stepper output').text()).toBe('10 rounds')
for (let index = 0; index < 4; index += 1) await wrapper.get('.round-stepper-decrease').trigger('click')
expect(wrapper.get('.round-stepper output').text()).toBe('6 rounds')
await wrapper.get('.round-settings-trigger').trigger('click')
const range = wrapper.get('.round-range-input')
await range.setValue(1)
expect(wrapper.get('.round-settings-popover output').text()).toBe('1 rounds')
await range.setValue(10)
expect(wrapper.get('.round-settings-popover output').text()).toBe('10 rounds')
await range.setValue(6)
await wrapper.get('.round-unlimited-toggle').setValue(true)
expect(range.attributes()).toHaveProperty('disabled')
expect(wrapper.get('.round-settings-trigger').text()).toContain('Unlimited')
await wrapper.get('.round-unlimited-toggle').setValue(false)
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
await flushPromises()
expect(wrapper.find('.round-settings-popover').exists()).toBe(false)
await wrapper.get('.composer-box textarea').setValue('Continue the review')
await wrapper.get('.send-button').trigger('click')
@@ -501,6 +580,57 @@ describe('RoundRelay workbench', () => {
wrapper.unmount()
})
it('sends unlimited automatic discussions without a round cap', async () => {
const { wrapper, bridge } = await mountApp(({ state, bridge: desktopBridge }) => {
state.groups.push({
id: 'group-1',
conversationType: 'group',
name: 'Open discussion',
topic: '',
agentKinds: ['codex', 'hermes'],
workdir: '/tmp/roundrelay-workspace',
allowWrite: false,
createdAt: '2026-07-29T08:00:00Z',
updatedAt: '2026-07-29T08:00:00Z',
})
desktopBridge.localWorkspace.send.mockImplementation(async (input) => {
state.runningGroupIds = [input.groupId]
state.runs = [{
groupId: input.groupId,
mode: 'auto',
targetKinds: input.targetKinds,
completedKinds: [],
currentKind: 'codex',
currentRound: 1,
maxRounds: 0,
unlimitedRounds: true,
progress: [],
}]
return structuredClone(state)
})
})
await wrapper.get('.conversation-link').trigger('click')
await wrapper.get('.round-settings-trigger').trigger('click')
await wrapper.get('.round-unlimited-toggle').setValue(true)
await wrapper.get('.composer-box textarea').setValue('Continue until consensus')
await wrapper.get('.send-button').trigger('click')
await flushPromises()
expect(bridge.localWorkspace.send).toHaveBeenCalledWith({
groupId: 'group-1',
text: 'Continue until consensus',
targetKinds: ['codex', 'hermes'],
skillHints: [],
attachments: [],
mode: 'auto',
maxRounds: 6,
unlimitedRounds: true,
})
expect(wrapper.get('.run-round-progress').text()).toBe('Round 1 / Unlimited')
wrapper.unmount()
})
it('creates direct chats and local Agent groups', async () => {
const { wrapper, bridge, state } = await mountApp()
@@ -846,7 +976,7 @@ describe('RoundRelay workbench', () => {
wrapper.unmount()
})
it('loads and sends only skills belonging to the currently selected targets', async () => {
it('mentions one Agent, scopes its Skills, and sends only to that Agent', async () => {
const { wrapper, bridge } = await mountApp(({ state, bridge: desktopBridge }) => {
state.groups.push({
id: 'group-1',
@@ -870,10 +1000,19 @@ describe('RoundRelay workbench', () => {
})
await wrapper.get('.conversation-link').trigger('click')
await wrapper.get('.mode-segmented [data-mode="manual"]').trigger('click')
await wrapper.findAll('.target-chip')[1].trigger('click')
bridge.agentInstaller.skills.mockClear()
const textarea = wrapper.get('.composer-box textarea')
await textarea.setValue('@co')
await flushPromises()
expect(wrapper.findAll('.agent-mention-option')).toHaveLength(1)
expect(wrapper.get('.agent-mention-option').text()).toContain('Codex')
expect(wrapper.get('.agent-mention-option').text()).toContain('Repository-scale coding')
await wrapper.get('.agent-mention-option').trigger('click')
expect(wrapper.get('.selected-agent-tag').text()).toContain('Codex')
expect(wrapper.get('.mode-segmented [data-mode="manual"]').classes()).toContain('active')
expect(wrapper.get('.mode-segmented [data-mode="auto"]').attributes()).toHaveProperty('disabled')
bridge.agentInstaller.skills.mockClear()
await textarea.setValue('@rev')
await flushPromises()
@@ -884,7 +1023,7 @@ describe('RoundRelay workbench', () => {
expect(textarea.attributes('role')).toBe('combobox')
expect(textarea.attributes('aria-expanded')).toBe('true')
expect(textarea.attributes('aria-controls')).toBe('composer-skill-menu')
expect(textarea.attributes('aria-activedescendant')).toBe('composer-skill-option-0')
expect(textarea.attributes('aria-activedescendant')).toBe('composer-mention-option-0')
await textarea.setValue('@review')
await flushPromises()
@@ -900,11 +1039,186 @@ describe('RoundRelay workbench', () => {
groupId: 'group-1',
text: 'Review this implementation',
targetKinds: ['codex'],
mentionedAgentKinds: ['codex'],
skillHints: [{ targetKind: 'codex', namespace: 'quality', slug: 'review', name: 'Review code' }],
attachments: [],
mode: 'manual',
maxRounds: 6,
})
expect(wrapper.find('.selected-agent-tag').exists()).toBe(false)
wrapper.unmount()
})
it('supports multiple Agent Tags and removes Skills with their Agent', async () => {
const { wrapper } = await mountApp(({ state, bridge: desktopBridge }) => {
state.groups.push({
id: 'group-1',
conversationType: 'group',
name: 'Review',
topic: '',
agentKinds: ['codex', 'hermes'],
workdir: '/tmp/roundrelay-workspace',
allowWrite: false,
createdAt: '2026-07-29T08:00:00Z',
updatedAt: '2026-07-29T08:00:00Z',
})
desktopBridge.agentInstaller.skills.mockImplementation(async kind => ({
skills: kind === 'hermes'
? [{ targetKind: 'hermes', namespace: 'research', slug: 'sources', name: 'Find sources' }]
: [],
}))
})
await wrapper.get('.conversation-link').trigger('click')
const textarea = wrapper.get('.composer-box textarea')
await textarea.setValue('@cod')
await wrapper.get('.agent-mention-option').trigger('click')
await textarea.setValue('@her')
await flushPromises()
await wrapper.get('.agent-mention-option').trigger('click')
expect(wrapper.findAll('.selected-agent-tag').map(tag => tag.text()))
.toEqual(['Codex', 'Hermes'])
await textarea.setValue('@find')
await flushPromises()
expect(wrapper.findAll('.skill-option')).toHaveLength(1)
expect(wrapper.get('.skill-option').text()).toContain('Find sources')
await textarea.trigger('keydown', { key: 'Enter' })
expect(wrapper.get('.selected-skill').text()).toContain('Find sources')
await wrapper.findAll('.selected-agent-tag button')[1].trigger('click')
expect(wrapper.findAll('.selected-agent-tag').map(tag => tag.text())).toEqual(['Codex'])
expect(wrapper.find('.selected-skill').exists()).toBe(false)
wrapper.unmount()
})
it('sends multiple Agent Tags without queueing unmentioned group members', async () => {
const { wrapper, bridge } = await mountApp(({ state }) => {
state.agents.push({
kind: 'qwen',
installed: true,
available: true,
credentialState: 'ready',
version: '1.0.0',
})
state.groups.push({
id: 'group-1',
conversationType: 'group',
name: 'Review',
topic: '',
agentKinds: ['codex', 'hermes', 'qwen'],
workdir: '/tmp/roundrelay-workspace',
allowWrite: false,
createdAt: '2026-07-29T08:00:00Z',
updatedAt: '2026-07-29T08:00:00Z',
})
})
await wrapper.get('.conversation-link').trigger('click')
const textarea = wrapper.get('.composer-box textarea')
await textarea.setValue('@cod')
await wrapper.get('.agent-mention-option').trigger('click')
await textarea.setValue('@her')
await flushPromises()
await wrapper.get('.agent-mention-option').trigger('click')
await textarea.setValue('Compare your findings')
await wrapper.get('.send-button').trigger('click')
await flushPromises()
expect(bridge.localWorkspace.send).toHaveBeenCalledWith({
groupId: 'group-1',
text: 'Compare your findings',
targetKinds: ['codex', 'hermes'],
mentionedAgentKinds: ['codex', 'hermes'],
skillHints: [],
attachments: [],
mode: 'manual',
maxRounds: 6,
})
wrapper.unmount()
})
it('restores Agent Tags and Skills when a targeted send fails', async () => {
const { wrapper } = await mountApp(({ state, bridge: desktopBridge }) => {
state.groups.push({
id: 'group-1',
conversationType: 'group',
name: 'Research',
topic: '',
agentKinds: ['codex', 'hermes'],
workdir: '/tmp/roundrelay-workspace',
allowWrite: false,
createdAt: '2026-07-29T08:00:00Z',
updatedAt: '2026-07-29T08:00:00Z',
})
desktopBridge.agentInstaller.skills.mockResolvedValue({
skills: [{ targetKind: 'hermes', namespace: 'research', slug: 'sources', name: 'Find sources' }],
})
desktopBridge.localWorkspace.send.mockRejectedValueOnce(new Error('LOCAL_AGENT_EXECUTION_FAILED'))
})
await wrapper.get('.conversation-link').trigger('click')
const textarea = wrapper.get('.composer-box textarea')
await textarea.setValue('@her')
await wrapper.get('.agent-mention-option').trigger('click')
await textarea.setValue('@find')
await flushPromises()
await textarea.trigger('keydown', { key: 'Enter' })
await textarea.setValue('Research this market')
await wrapper.get('.send-button').trigger('click')
await flushPromises()
expect(textarea.element.value).toBe('Research this market')
expect(wrapper.get('.selected-agent-tag').text()).toContain('Hermes')
expect(wrapper.get('.selected-skill').text()).toContain('Find sources')
wrapper.unmount()
})
it('keeps direct chats on their Agent Skills without Agent Tags', async () => {
const { wrapper, bridge } = await mountApp(({ state, bridge: desktopBridge }) => {
state.groups.push({
id: 'direct-codex',
conversationType: 'direct',
directAgentKind: 'codex',
name: 'Codex',
agentKinds: ['codex'],
workdir: '/tmp/roundrelay-workspace',
allowWrite: false,
createdAt: '2026-07-29T08:00:00Z',
updatedAt: '2026-07-29T08:00:00Z',
})
desktopBridge.agentInstaller.skills.mockImplementation(async kind => ({
skills: kind === 'codex'
? [{ targetKind: 'codex', namespace: 'quality', slug: 'review', name: 'Review code' }]
: [],
}))
})
await wrapper.get('.direct-session-open').trigger('click')
bridge.agentInstaller.skills.mockClear()
const textarea = wrapper.get('.composer-box textarea')
await textarea.setValue('@review')
await flushPromises()
expect(bridge.agentInstaller.skills.mock.calls).toEqual([['codex']])
expect(wrapper.find('.agent-mention-option').exists()).toBe(false)
expect(wrapper.get('.skill-option').text()).toContain('Review code')
await textarea.trigger('keydown', { key: 'Enter' })
await textarea.setValue('Review this implementation')
await wrapper.get('.send-button').trigger('click')
await flushPromises()
expect(bridge.localWorkspace.send).toHaveBeenCalledWith({
groupId: 'direct-codex',
text: 'Review this implementation',
targetKinds: ['codex'],
skillHints: [{ targetKind: 'codex', namespace: 'quality', slug: 'review', name: 'Review code' }],
attachments: [],
mode: 'manual',
maxRounds: 6,
})
expect(wrapper.find('.selected-agent-tag').exists()).toBe(false)
wrapper.unmount()
})
@@ -1270,6 +1584,49 @@ describe('RoundRelay workbench', () => {
wrapper.unmount()
})
it('renders Agent image, audio, and video outputs inside the conversation', async () => {
const { wrapper, bridge } = await mountApp(({ state }) => {
state.groups.push({
id: 'direct-codex',
conversationType: 'direct',
directAgentKind: 'codex',
name: 'Codex',
agentKinds: ['codex'],
workdir: '/tmp/roundrelay-workspace',
allowWrite: true,
createdAt: '2026-07-29T08:00:00Z',
updatedAt: '2026-07-29T08:00:00Z',
})
state.messages.push({
id: 'agent-media',
groupId: 'direct-codex',
role: 'agent',
agentKind: 'codex',
content: 'Generated media is ready.',
attachments: [
{ id: 'poster-image', name: 'poster.png', mimeType: 'image/png', size: 3 },
{ id: 'briefing-audio', name: 'briefing.mp3', mimeType: 'audio/mpeg', size: 12 },
{ id: 'demo-video', name: 'demo.mp4', mimeType: 'video/mp4', size: 24 },
],
createdAt: '2026-07-29T08:01:00Z',
})
})
await wrapper.get('.direct-session-open').trigger('click')
await flushPromises()
expect(bridge.localAttachments.preview).toHaveBeenCalledTimes(1)
expect(bridge.localAttachments.preview).toHaveBeenCalledWith('poster-image')
expect(wrapper.get('.message-attachment-grid img').attributes('src')).toBe('data:image/png;base64,AQID')
expect(wrapper.get('.message-attachment-grid audio').attributes('src'))
.toBe('meldwork-media://attachment/briefing-audio')
expect(wrapper.get('.message-attachment-grid video').attributes('src'))
.toBe('meldwork-media://attachment/demo-video')
expect(wrapper.findAll('.message-attachment-grid figcaption').map(item => item.text()))
.toEqual(['poster.png', 'briefing.mp3', 'demo.mp4'])
wrapper.unmount()
})
it('blocks button and Enter sends until a pasted image finishes importing', async () => {
const pendingImport = deferred()
const { wrapper, bridge } = await mountApp(({ state, bridge: desktopBridge }) => {
@@ -1586,6 +1943,49 @@ describe('RoundRelay workbench', () => {
wrapper.unmount()
})
it('keeps group icons transparent and exposes safe sidebar rename and delete actions', async () => {
const source = readFileSync(resolve(process.cwd(), 'src/styles.css'), 'utf8')
expect(source).toMatch(/\.group-avatar\s*\{[^}]*background:\s*transparent;/s)
expect(source).toMatch(/\.group-conversation-row:hover \.group-conversation-actions/)
const { wrapper, bridge } = await mountApp(({ state }) => {
state.groups.push({
id: 'group-sidebar-actions',
conversationType: 'group',
name: 'Market research',
topic: '',
agentKinds: ['codex', 'hermes'],
workdir: '/tmp/roundrelay-workspace',
allowWrite: false,
createdAt: '2026-07-29T08:00:00Z',
updatedAt: '2026-07-29T08:00:00Z',
})
})
const row = wrapper.get('.group-conversation-row')
const actions = row.findAll('.direct-session-action')
expect(actions).toHaveLength(2)
expect(actions[0].attributes('aria-label')).toBe('Rename group Market research')
expect(actions[1].attributes('aria-label')).toBe('Delete group Market research')
await actions[0].trigger('click')
expect(wrapper.get('#modal-title').text()).toBe('Rename group chat')
await wrapper.get('form.form-stack input:not([type="checkbox"])').setValue('GEO research')
await wrapper.get('form.form-stack').trigger('submit')
await flushPromises()
expect(bridge.localWorkspace.updateGroup).toHaveBeenCalledWith(
'group-sidebar-actions',
expect.objectContaining({ name: 'GEO research' }),
)
await wrapper.get('.group-conversation-row .direct-session-action.danger').trigger('click')
expect(wrapper.get('.danger-button').text()).toContain('Confirm delete')
await wrapper.get('.danger-button').trigger('click')
await flushPromises()
expect(bridge.localWorkspace.deleteGroup).toHaveBeenCalledWith('group-sidebar-actions')
wrapper.unmount()
})
it('dismisses only the known plan warning for the component lifetime and closes the global toast', async () => {
const { wrapper, bridge, emitWorkspaceChanged } = await mountApp(({ state, bridge: desktopBridge }) => {
state.groups.push({
@@ -1648,7 +2048,7 @@ describe('RoundRelay workbench', () => {
wrapper.unmount()
})
it('maps direct replies and active runs onto the turn rail, then expands and scrolls on selection', async () => {
it('maps direct tasks onto the turn rail without group reply styling', async () => {
const scrollIntoView = vi.fn()
HTMLElement.prototype.scrollIntoView = scrollIntoView
const { wrapper } = await mountApp(({ state }) => {
@@ -1713,17 +2113,18 @@ describe('RoundRelay workbench', () => {
const railButtons = wrapper.findAll('.turn-rail button')
expect(railButtons).toHaveLength(2)
expect(railButtons[0].attributes('aria-label')).toContain('First direct task')
expect(railButtons[0].attributes('aria-label')).toContain('1 reply')
expect(railButtons[0].attributes('aria-label')).not.toContain('reply')
expect(railButtons[0].attributes('aria-label')).toContain('Completed')
expect(railButtons[0].get('.turn-rail-tooltip').text()).toBe(railButtons[0].attributes('aria-label'))
expect(railButtons[1].attributes('data-status')).toBe('running')
expect(railButtons[1].attributes('aria-current')).toBe('true')
expect(wrapper.get('#message-agent-1').classes()).toContain('topic-reply')
expect(wrapper.get('#message-agent-1').classes()).toContain('direct-message')
expect(wrapper.get('#message-agent-1').classes()).not.toContain('topic-reply')
expect(wrapper.find('.topic-toggle').exists()).toBe(false)
expect(wrapper.findAll('.active-topic-label').every(label => label.text() === 'Current task')).toBe(true)
expect(wrapper.get('.conversation-title-button').attributes()).toHaveProperty('disabled')
expect(wrapper.find('.run-round-progress').exists()).toBe(false)
await wrapper.findAll('.topic-toggle')[0].trigger('click')
expect(wrapper.find('#message-agent-1').exists()).toBe(false)
scrollIntoView.mockClear()
await railButtons[0].trigger('click')
await flushPromises()
@@ -1789,6 +2190,14 @@ describe('RoundRelay workbench', () => {
await wrapper.get('.message-row.user .message-copy-button').trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(writeText).toHaveBeenCalledWith('Review the selected text')
const execCommand = vi.fn(() => true)
Object.defineProperty(document, 'execCommand', { configurable: true, value: execCommand })
writeText.mockRejectedValueOnce(new Error('Clipboard permission denied'))
await wrapper.get('.message-row.agent .message-copy-surface').trigger('click')
await flushPromises()
expect(execCommand).toHaveBeenCalledWith('copy')
expect(wrapper.find('.toast-message').exists()).toBe(false)
wrapper.unmount()
})
@@ -1813,6 +2222,7 @@ describe('RoundRelay workbench', () => {
groupId: 'group-1',
role: 'user',
content: 'Review this change',
targetKinds: ['codex'],
createdAt: '2026-07-29T08:01:00Z',
},
{
@@ -1834,6 +2244,10 @@ describe('RoundRelay workbench', () => {
})
await wrapper.get('.conversation-link').trigger('click')
expect(wrapper.get('.message-target-list').text()).toBe('Codex')
expect(wrapper.get('.message-row.agent').classes()).toContain('group-message')
expect(wrapper.get('.message-row.agent').classes()).toContain('topic-reply')
expect(wrapper.get('.topic-toggle').text()).toContain('1 reply')
await wrapper.get('.topic-toggle').trigger('click')
expect(wrapper.find('.message-row.agent').exists()).toBe(false)
@@ -1865,13 +2279,18 @@ describe('RoundRelay workbench', () => {
.toEqual(['completed', 'running', 'queued'])
expect(wrapper.findAll('.run-agent-row').map(row => row.text()))
.toEqual(['CodexCompleted', 'HermesRunning', 'Qwen CodeQueued'])
const agentRows = wrapper.findAll('.run-agent-row')
expect(agentRows[0].find('.run-agent-motion[data-status="completed"] svg').exists()).toBe(true)
expect(agentRows[1].findAll('.run-agent-bars i')).toHaveLength(3)
expect(agentRows[2].findAll('.run-agent-dots i')).toHaveLength(3)
expect(wrapper.findAll('.execution-details')).toHaveLength(2)
expect(wrapper.findAll('.execution-details').every(details => details.attributes('open') === undefined)).toBe(true)
expect(wrapper.get('.run-progress-details').element.tagName).toBe('DIV')
const runningStep = wrapper.findAll('.run-progress-details li')[1].get('small')
expect(runningStep.text()).toBe('Running')
expect(runningStep.classes()).toContain('running')
await wrapper.findAll('.workspace-preferences button')[0].trigger('click')
await wrapper.findAll('.sidebar-footer-actions button')[0].trigger('click')
const detailsText = wrapper.findAll('.execution-details').map(details => details.text()).join(' ')
expect(detailsText).toContain('运行进程')
expect(detailsText).toContain('写入文件')
+82 -14
View File
@@ -28,6 +28,7 @@ const messages = {
'nav.groups': 'Groups',
'nav.agents': 'Local Agents',
'nav.provider': 'Provider',
'nav.settings': 'Settings',
'nav.noDirect': 'No direct chats yet',
'nav.noSidebarAgents': 'No ready Agents are shown',
'nav.agentSessions': '{count} chats',
@@ -36,6 +37,9 @@ const messages = {
'nav.openDirect': 'Open {name}',
'nav.renameDirect': 'Rename {name}',
'nav.deleteDirect': 'Delete {name}',
'nav.openGroup': 'Open {name}',
'nav.renameGroup': 'Rename group {name}',
'nav.deleteGroup': 'Delete group {name}',
'nav.runFinished': 'Run finished',
'nav.noGroups': 'No groups yet',
'home.title': 'Local Agents',
@@ -59,15 +63,25 @@ const messages = {
'agent.images': 'Up to {count} images',
'agent.noImages': 'No image input',
'agent.providerShort.compatible': 'OpenAI compatible',
'agent.providerShort.experimental': 'Experimental shared Provider',
'agent.providerShort.experimental': 'Experimental external Provider',
'agent.providerShort.responses': 'Responses API',
'agent.providerShort.anthropic': 'Anthropic compatible',
'agent.providerShort.native': 'Native Provider',
'agent.provider.compatible': 'Shared OpenAI-compatible Provider',
'agent.provider.experimental': 'Shared Provider support is experimental',
'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',
'agent.provider.anthropic': 'Uses an Anthropic-compatible Provider',
'agent.provider.native': 'Uses its own local Provider settings',
'agent.description.codex': 'Repository-scale coding, precise implementation, and dependable tool use.',
'agent.description.hermes': 'Flexible open-source reasoning for research, planning, and long-running tasks.',
'agent.description.openclaw': 'Autonomous local workflows with persistent execution and managed task context.',
'agent.description.workbuddy': 'Practical coding assistance for understanding and changing existing projects.',
'agent.description.kimi': 'Long-context code reading and planning for large, complex repositories.',
'agent.description.mimo': 'Efficient local coding for focused implementation and iteration.',
'agent.description.claude': 'Strong code reasoning, architecture review, and careful multi-file changes.',
'agent.description.gemini': 'Broad context and multimodal workflows from the Google developer ecosystem.',
'agent.description.opencode': 'Transparent, permission-aware terminal coding with flexible model support.',
'agent.description.qwen': 'Open-source coding with strong Chinese support and compatible model options.',
'conversation.members': '{count} Agents',
'conversation.direct': 'Direct chat',
'conversation.directDefaultName': '{agent} chat {count}',
@@ -86,6 +100,7 @@ const messages = {
'conversation.runningGeneric': 'Agent is working',
'conversation.directWorking': '{agent} is working in this chat',
'conversation.groupWorking': 'Agents are relaying this topic',
'conversation.activeTask': 'Current task',
'conversation.activeTopic': 'Running topic',
'conversation.topicReply': '{count} reply',
'conversation.topicReplies': '{count} replies',
@@ -96,6 +111,7 @@ const messages = {
'conversation.seconds': '{count}s',
'conversation.minutes': '{count}m',
'conversation.turnRail': 'Conversation turns',
'conversation.turnRailDirectLabel': '{query}. {time}. {status}.',
'conversation.turnRailLabel': '{query}. {time}. {replies}. {status}.',
'conversation.timeUnknown': 'Time unavailable',
'conversation.attachmentTurn': 'Attachment message',
@@ -108,6 +124,7 @@ const messages = {
'run.progress': 'Run progress',
'run.agents': 'Agent status',
'run.roundProgress': 'Round {current}/{max}',
'run.roundProgressUnlimited': 'Round {current} / Unlimited',
'run.status.pending': 'Pending',
'run.status.queued': 'Queued',
'run.status.running': 'Running',
@@ -120,10 +137,15 @@ const messages = {
'run.status.timeout': 'Timed out',
'run.status.roundLimit': 'Round limit reached',
'run.status.unknown': 'Updated',
'run.step.reasoning': 'Analyze task',
'run.step.process': 'Run process',
'run.step.writeFile': 'Write file',
'run.step.readFile': 'Read file',
'run.step.search': 'Search',
'run.step.imageGeneration': 'Generate image',
'run.step.audioGeneration': 'Generate audio',
'run.step.videoGeneration': 'Generate video',
'run.step.tool': 'Use tool',
'run.step.generic': 'Execution step {count}',
'composer.placeholder': 'Message {name}',
'composer.targets': 'Replying Agents',
@@ -135,8 +157,7 @@ const messages = {
'composer.startAuto': 'Start discussion',
'composer.autoRounds': '{count} rounds',
'composer.maxRounds': 'Maximum rounds',
'composer.decreaseRounds': 'Decrease maximum rounds',
'composer.increaseRounds': 'Increase maximum rounds',
'composer.unlimitedRounds': 'Unlimited',
'composer.addTarget': 'Include {agent}',
'composer.removeTarget': 'Exclude {agent}',
'composer.autoTarget': '{agent} participates in automatic discussion',
@@ -144,8 +165,13 @@ const messages = {
'composer.selectTarget': 'Select at least one Agent.',
'composer.messageRequired': 'Enter a message.',
'composer.skills': 'Skills',
'composer.mentions': 'Agents and skills',
'composer.mentionAgent': 'Mention Agent',
'composer.mentionedAgents': 'Mentioned Agents',
'composer.removeMention': 'Remove {agent}',
'composer.skillsLoading': 'Loading skills',
'composer.noSkills': 'No matching skills for the selected Agents.',
'composer.noMentions': 'No matching Agents or skills.',
'composer.skillLimit': 'You can add up to 4 skills.',
'composer.removeSkill': 'Remove skill',
'composer.attachImages': 'Attach images',
@@ -170,11 +196,17 @@ const messages = {
'group.createErrorWorkspace': 'Choose a working directory.',
'settings.title': 'Conversation settings',
'settings.renameDirectTitle': 'Rename direct chat',
'settings.renameGroupTitle': 'Rename group chat',
'settings.conversationName': 'Conversation name',
'settings.save': 'Save changes',
'settings.delete': 'Delete conversation',
'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 and their individual Provider connections.',
'systemSettings.agents': 'Agents',
'systemSettings.providers': 'Providers',
'systemSettings.providerForAgent': 'Provider',
'installer.title': 'Manage local Agents',
'installer.subtitle': 'Detection and installation run on this computer.',
'installer.refresh': 'Refresh catalog',
@@ -191,8 +223,10 @@ const messages = {
'installer.phase.failed': 'Installation failed',
'installer.unsupported': 'Install manually on this system',
'installer.nodeRequired': 'Node.js and npm are required',
'provider.title': 'Shared Provider',
'provider.subtitle': 'Used by compatible local Agents. Credentials are encrypted by the desktop app.',
'provider.title': 'Agent Provider',
'provider.subtitle': 'Each compatible Agent keeps its own encrypted Provider credentials.',
'provider.agentTitle': '{agent} Provider',
'provider.agentSpecificHint': 'This connection is used only by the selected Agent. Other Agents keep their own Provider or native sign-in.',
'provider.name': 'Provider name',
'provider.namePlaceholder': 'My gateway',
'provider.baseUrl': 'Base URL',
@@ -298,6 +332,7 @@ const messages = {
'nav.groups': '群聊',
'nav.agents': '本地 Agent',
'nav.provider': 'Provider',
'nav.settings': '设置',
'nav.noDirect': '还没有单聊',
'nav.noSidebarAgents': '暂无显示在侧栏的可用 Agent',
'nav.agentSessions': '{count} 个会话',
@@ -306,6 +341,9 @@ const messages = {
'nav.openDirect': '打开{name}',
'nav.renameDirect': '重命名{name}',
'nav.deleteDirect': '删除{name}',
'nav.openGroup': '打开群聊{name}',
'nav.renameGroup': '重命名群聊{name}',
'nav.deleteGroup': '删除群聊{name}',
'nav.runFinished': '运行已完成',
'nav.noGroups': '还没有群聊',
'home.title': '本地 Agent',
@@ -329,15 +367,25 @@ const messages = {
'agent.images': '最多 {count} 张图片',
'agent.noImages': '不支持图片输入',
'agent.providerShort.compatible': 'OpenAI 兼容',
'agent.providerShort.experimental': '实验性共享 Provider',
'agent.providerShort.experimental': '实验性外部 Provider',
'agent.providerShort.responses': 'Responses API',
'agent.providerShort.anthropic': 'Anthropic 兼容',
'agent.providerShort.native': '本机 Provider',
'agent.provider.compatible': '可使用共享 OpenAI 兼容 Provider',
'agent.provider.experimental': '共享 Provider 为实验性支持',
'agent.provider.compatible': '可单独配置 OpenAI 兼容 Provider',
'agent.provider.experimental': '独立外部 Provider 为实验性支持',
'agent.provider.responses': '使用兼容 Responses API 的 Provider',
'agent.provider.anthropic': '使用 Anthropic 兼容 Provider',
'agent.provider.native': '沿用本机已有 Provider 配置',
'agent.description.codex': '适合仓库级编码、精确实现与可靠的工具调用。',
'agent.description.hermes': '适合开源推理、调研规划与持续时间较长的复杂任务。',
'agent.description.openclaw': '适合自主本地工作流、持久执行与任务上下文管理。',
'agent.description.workbuddy': '适合理解现有工程并完成务实、连续的代码修改。',
'agent.description.kimi': '擅长长上下文代码阅读、大型仓库理解与复杂变更规划。',
'agent.description.mimo': '适合高效的本地编码、聚焦实现与快速迭代。',
'agent.description.claude': '擅长代码推理、架构审查与谨慎的多文件修改。',
'agent.description.gemini': '具备广泛上下文能力,适合 Google 生态与多模态开发流程。',
'agent.description.opencode': '强调透明权限控制,适合灵活模型驱动的终端编码。',
'agent.description.qwen': '开源且中文能力突出,适合兼容模型下的代码任务。',
'conversation.members': '{count} 个 Agent',
'conversation.direct': '单聊',
'conversation.directDefaultName': '{agent} 会话 {count}',
@@ -356,6 +404,7 @@ const messages = {
'conversation.runningGeneric': 'Agent 正在工作',
'conversation.directWorking': '{agent} 正在当前会话中工作',
'conversation.groupWorking': '多个 Agent 正在接力处理该话题',
'conversation.activeTask': '当前任务',
'conversation.activeTopic': '运行中的话题',
'conversation.topicReply': '{count} 条回复',
'conversation.topicReplies': '{count} 条回复',
@@ -366,6 +415,7 @@ const messages = {
'conversation.seconds': '{count} 秒',
'conversation.minutes': '{count} 分钟',
'conversation.turnRail': '对话轮次',
'conversation.turnRailDirectLabel': '{query};{time};{status}',
'conversation.turnRailLabel': '{query};{time};{replies};{status}',
'conversation.timeUnknown': '时间未知',
'conversation.attachmentTurn': '附件消息',
@@ -378,6 +428,7 @@ const messages = {
'run.progress': '运行进度',
'run.agents': 'Agent 状态',
'run.roundProgress': '第 {current}/{max} 轮',
'run.roundProgressUnlimited': '第 {current} 轮 / 不限制',
'run.status.pending': '待处理',
'run.status.queued': '排队中',
'run.status.running': '进行中',
@@ -390,10 +441,15 @@ const messages = {
'run.status.timeout': '已超时',
'run.status.roundLimit': '达到轮数上限',
'run.status.unknown': '已更新',
'run.step.reasoning': '分析任务',
'run.step.process': '运行进程',
'run.step.writeFile': '写入文件',
'run.step.readFile': '读取文件',
'run.step.search': '搜索',
'run.step.imageGeneration': '生成图片',
'run.step.audioGeneration': '生成音频',
'run.step.videoGeneration': '生成视频',
'run.step.tool': '调用工具',
'run.step.generic': '执行步骤 {count}',
'composer.placeholder': '发消息给 {name}',
'composer.targets': '回复 Agent',
@@ -405,8 +461,7 @@ const messages = {
'composer.startAuto': '开始讨论',
'composer.autoRounds': '{count} 轮',
'composer.maxRounds': '最大轮数',
'composer.decreaseRounds': '减少最大轮数',
'composer.increaseRounds': '增加最大轮数',
'composer.unlimitedRounds': '不限制',
'composer.addTarget': '加入 {agent}',
'composer.removeTarget': '移除 {agent}',
'composer.autoTarget': '{agent} 参与自动讨论',
@@ -414,8 +469,13 @@ const messages = {
'composer.selectTarget': '请至少选择一个 Agent。',
'composer.messageRequired': '请输入消息。',
'composer.skills': '技能',
'composer.mentions': 'Agent 与技能',
'composer.mentionAgent': '指定 Agent',
'composer.mentionedAgents': '已指定 Agent',
'composer.removeMention': '移除 {agent}',
'composer.skillsLoading': '正在加载技能',
'composer.noSkills': '当前选中的 Agent 没有匹配技能。',
'composer.noMentions': '没有匹配的 Agent 或技能。',
'composer.skillLimit': '最多添加 4 个技能。',
'composer.removeSkill': '移除技能',
'composer.attachImages': '添加图片',
@@ -440,11 +500,17 @@ const messages = {
'group.createErrorWorkspace': '请选择工作目录。',
'settings.title': '对话设置',
'settings.renameDirectTitle': '重命名单聊',
'settings.renameGroupTitle': '重命名群聊',
'settings.conversationName': '会话名称',
'settings.save': '保存修改',
'settings.delete': '删除对话',
'settings.deleteConfirm': '确认删除',
'settings.deleteHint': '仅删除 Meldwork 中的该对话及已保存消息,不会删除 Agent 原生 CLI 会话。',
'systemSettings.title': '设置',
'systemSettings.subtitle': '管理本地 Agent 及各自独立的 Provider 连接。',
'systemSettings.agents': 'Agent',
'systemSettings.providers': 'Provider',
'systemSettings.providerForAgent': 'Provider',
'installer.title': '管理本地 Agent',
'installer.subtitle': '检测和安装均在这台电脑上进行。',
'installer.refresh': '刷新列表',
@@ -461,8 +527,10 @@ const messages = {
'installer.phase.failed': '安装失败',
'installer.unsupported': '当前系统需要手动安装',
'installer.nodeRequired': '需要先安装 Node.js 与 npm',
'provider.title': '共享 Provider',
'provider.subtitle': '供兼容的本地 Agent 使用,凭据由桌面端加密保存。',
'provider.title': 'Agent Provider',
'provider.subtitle': '每个兼容 Agent 分别保存自己的加密 Provider 凭据。',
'provider.agentTitle': '{agent} Provider',
'provider.agentSpecificHint': '该连接仅供当前 Agent 使用,其他 Agent 继续使用各自的 Provider 或原生登录。',
'provider.name': 'Provider 名称',
'provider.namePlaceholder': '我的网关',
'provider.baseUrl': 'Base URL',
+905 -153
View File
File diff suppressed because it is too large. Load diff