mirror of
https://github.com/Ryder-Sun/Meldwork.git
synced 2026-11-07 06:56:03 -10:00
Compare commits
19
Commits
feature/1.0.6
...
1.0.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f10fd14d9 | ||
|
|
d11deef490 | ||
|
|
bd395b7a2f | ||
|
|
4c522c0997 | ||
|
|
9971659e8a | ||
|
|
83709a13e8 | ||
|
|
5a938f60d2 | ||
|
|
a97fd91975 | ||
|
|
fbc6e08e80 | ||
|
|
0591472bc4 | ||
|
|
35b8075b87 | ||
|
|
c92b30c2c6 | ||
|
|
67d4084de6 | ||
|
|
7f9e95d86c | ||
|
|
f77fd8b0c0 | ||
|
|
6983e58791 | ||
|
|
c01f96b4c7 | ||
|
|
a501718400 | ||
|
|
7deb8a3d12 |
No files matched your search
@@ -31,6 +31,9 @@ jobs:
|
||||
- name: Run unit tests
|
||||
run: npm test
|
||||
|
||||
- name: Check website and release licensing consistency
|
||||
run: node --test ../meldwork-landing/test/landing.test.cjs
|
||||
|
||||
- name: Build web application
|
||||
run: npm run build
|
||||
|
||||
|
||||
+13
-1
@@ -1,5 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.7 - 2026-10-27 (Meldwork-V1.0.7 prerelease)
|
||||
|
||||
- Preserve streamed discussion replies across cancellation and reload; scope ACP notifications to the active session.
|
||||
- Support Hermes native stream JSON and avoid MiMo interactive plan prompts in headless runs.
|
||||
- Add Zcode with its official logo before Pi Agent, native default configuration, and explicit missing-key errors.
|
||||
- Let WorkBuddy use an explicitly selected model from its desktop configuration, without exposing credentials to the renderer.
|
||||
- Keep Chinese slash-separated text intact while retaining credential and local-path redaction.
|
||||
- Unify application, artifact, README, and website versions at 1.0.7.
|
||||
- License Meldwork's original source, documentation, and website under Apache-2.0: commercial use is permitted without separate authorization or license fees. Third-party notices remain applicable. Older entries below describe their historical release terms.
|
||||
- Native acceptance includes a real WorkBuddy response; six-Agent unlimited discussion cancellation and reload were checked with simulated adapters. Zcode's default provider still needs a valid API key; the observed OpenCode router's invalid `tool_calls` finish reason remains an upstream issue.
|
||||
- Apple silicon DMG and ZIP remain ad-hoc signed and unnotarized.
|
||||
|
||||
## 0.1.6 - 2026-10-17 (Meldwork-V1.0.6 prerelease)
|
||||
|
||||
### Agent Reliability
|
||||
@@ -151,7 +163,7 @@
|
||||
- Sets the desktop package version to `0.1.2` with permanent Bundle ID `com.rydersun.meldwork`.
|
||||
- Produces Apple silicon DMG and ZIP prerelease artifacts using ad-hoc signing. This is not Apple Developer ID signing or notarization and may require Open Anyway on first launch.
|
||||
- Keeps `dist:public` as the fail-closed future formal-distribution path requiring a Developer ID identity, Hardened Runtime, complete Apple notarization credentials, and post-sign validation.
|
||||
- Uses the Meldwork Non-Commercial Source License; commercial use requires prior written permission.
|
||||
- This historical release used the Meldwork Non-Commercial Source License and required prior written permission for commercial use. Current releases use Apache-2.0 as stated above.
|
||||
|
||||
### Verification
|
||||
|
||||
|
||||
+20
-11
@@ -1,14 +1,23 @@
|
||||
# Commercial Use And Licensing
|
||||
# Commercial use / 商业使用
|
||||
|
||||
Meldwork is licensed under the [Apache License 2.0](LICENSE). Commercial use,
|
||||
private use, modification, redistribution, and use inside a paid service are
|
||||
permitted under the license terms.
|
||||
Meldwork's original source code, documentation, README files, and official website
|
||||
are licensed under [Apache License 2.0](LICENSE). Commercial use, private use,
|
||||
modification, redistribution, and inclusion in paid products or services are
|
||||
permitted under its terms. No separate commercial authorization or license fee
|
||||
is required.
|
||||
|
||||
This page is a practical summary and does not replace the license text. Apache
|
||||
License 2.0 does not grant trademark rights to the Meldwork name, logo,
|
||||
screenshots, or other brand assets. Third-party software, Agent products,
|
||||
models, names, logos, and other materials remain subject to their own licenses
|
||||
and terms.
|
||||
When redistributing, preserve the license, applicable copyright and attribution
|
||||
notices, and NOTICE information, and identify modified files as required by the
|
||||
license. Apache-2.0 does not grant trademark rights (Section 6).
|
||||
|
||||
Before distributing a product that includes third-party materials, review the
|
||||
relevant notices and preserve the required attribution and license information.
|
||||
Third-party dependencies, fonts, Agent names, and logos retain their own licenses
|
||||
and terms; their inclusion does not imply endorsement. In particular, the
|
||||
website's bundled Fusion Pixel font retains its OFL license and notices.
|
||||
|
||||
Meldwork 原创源代码、文档、README 和官网均采用 Apache-2.0。依照许可证条款,
|
||||
允许商业使用、私人使用、修改、再分发,以及用于收费产品或服务;无需另行取得商业
|
||||
授权或支付许可费。再分发时应保留许可证、适用的版权与归属声明及 NOTICE 信息,并
|
||||
按许可证要求标明修改。Apache-2.0 第 6 条不授予商标权。第三方依赖、字体、Agent
|
||||
名称和标志仍遵循各自条款;官网附带的 Fusion Pixel 字体保留 OFL 许可证与声明。
|
||||
|
||||
This summary does not replace LICENSE. 本说明不替代 LICENSE 正文。
|
||||
@@ -10,3 +10,7 @@ do not imply sponsorship or endorsement.
|
||||
The OpenCodeReview logo is sourced from Alibaba's OpenCodeReview project:
|
||||
https://github.com/alibaba/open-code-review/blob/main/imgs/logo-core.svg
|
||||
The upstream project is licensed under Apache License 2.0.
|
||||
|
||||
The Zcode logo is sourced unchanged from the official Zcode website:
|
||||
https://zcode.z.ai/favicon-192x192.png?v=20260707-transparent
|
||||
The logo identifies the integration; its trademark rights remain with its owner.
|
||||
@@ -6,37 +6,37 @@
|
||||
<strong>English</strong> · <a href="README.zh-CN.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
# Meldwork — A local-first workspace for General Agents
|
||||
# Meldwork — Let Agents collaborate to move tasks forward
|
||||
|
||||
**Turn several General Agents into a reviewable working group. Meldwork gives every task a shared context, lets selected Agents investigate independently or discuss across rounds, and keeps the evidence and human decision visible before anything is adopted.**
|
||||
**Set the goal. Let Agents work together. Meldwork brings your existing Agents into one working group to share context, discuss approaches, carry out work, and check each other's results — with less relaying, prompting, and stitching outputs together.**
|
||||
|
||||
Meldwork is a local-first Electron workspace for using the Agent tools you already have on your Mac. It supports focused conversations, independent concurrent responses, and Auto Discussion V4 for proposal, challenge, negotiated work, synthesis, and verification. Agents can work with text, files, images, media, Skills, and selected knowledge sources, so the same workspace can support research, analysis, writing, planning, review, and implementation.
|
||||
Meldwork is a local-first multi-Agent collaboration workspace for the Agent tools you already use on your Mac. Once you choose the participants, goal, context, and permissions, Agents in Auto Discussion can build on each other's work, request contributions or reviews, and advance the task within the authorized scope. Direct conversations and independent concurrent responses also support research, analysis, writing, planning, review, and implementation.
|
||||
|
||||
The product is organized around a durable review record: **Case → Finding → Evidence → Decision → Disposition**. Meldwork keeps the task snapshot, Agent contributions, run state, artifacts, Human Gates, and adoption decision together in one local work cell. Workspace writes are opt-in and remain under the user's control.
|
||||
You do not need to direct every round, but you still set the goal, grant permissions, and make key decisions. Missing information, permission requests, or tradeoffs may require your input. Workspace writes are opt-in; collaboration history, run state, and deliverables stay local so you can revisit the work and continue it.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Ryder-MHumble/Meldwork/releases/download/Meldwork-V1.0.5/Meldwork-0.1.5-arm64.dmg"><strong>Download Meldwork V1.0.5 for Apple silicon macOS</strong></a>
|
||||
<a href="https://github.com/Ryder-Sun/Meldwork/releases/download/Meldwork-V1.0.8/Meldwork-1.0.8-arm64.dmg"><strong>Download Meldwork V1.0.8 for Apple silicon macOS</strong></a>
|
||||
· <a href="architecture.md">Architecture</a>
|
||||
· <a href="LICENSE">License</a>
|
||||
</p>
|
||||
|
||||
## What Meldwork is for
|
||||
|
||||
Meldwork is useful when a decision benefits from more than one perspective and a clear record of why a result was adopted. Typical work includes:
|
||||
Meldwork is useful when several Agents need to advance the same task and you want to spend less time passing context and coordinating next steps. Typical work includes:
|
||||
|
||||
- research and information synthesis across local Agent tools and selected knowledge sources;
|
||||
- product, market, operational, and technical analysis;
|
||||
- writing, planning, review, and structured document work;
|
||||
- multimodal work with local documents, images, audio, video, PDFs, and code or configuration files;
|
||||
- implementation and review tasks where findings, evidence, permissions, and the final adoption decision should remain inspectable.
|
||||
- implementation in an authorized workspace, with other Agents checking the deliverables and requesting revisions.
|
||||
|
||||
## How a task moves through Meldwork
|
||||
|
||||
1. **Select** the Agents and participants for this Case.
|
||||
1. **Select** the Agents that will work on the task together.
|
||||
2. **Scope** the goal, working directory, context, attachments, Skills, knowledge sources, and permissions.
|
||||
3. **Run** a direct conversation, Concurrent Responses, or Auto Discussion V4.
|
||||
4. **Review** the findings, evidence, artifacts, trace, and any Human Gate.
|
||||
5. **Adopt** the approved result or leave the Case unresolved for further work.
|
||||
3. **Collaborate** in Auto Discussion: let Agents discuss approaches, carry out authorized work, and check results. Direct conversations and Concurrent Responses are also available.
|
||||
4. **Decide** when the work needs information, permission, or a tradeoff resolved.
|
||||
5. **Check the result** and request changes when more work is needed.
|
||||
|
||||
## Collaboration modes
|
||||
|
||||
@@ -44,21 +44,15 @@ Meldwork is useful when a decision benefits from more than one perspective and a
|
||||
| --- | --- | --- |
|
||||
| **Direct** | One selected Agent keeps its conversation and native session when supported. | Focused research, writing, analysis, or execution. |
|
||||
| **Concurrent Responses** | Selected Agents receive the same frozen task snapshot and return independent responses in stable order. | Comparing perspectives before choosing a path. |
|
||||
| **Auto Discussion V4** | Agents propose, challenge, negotiate responsibilities, execute agreed work, synthesize a candidate result, and verify it across bounded rounds. Native Agent sessions continue when supported. | Multi-step work that needs discussion, division of responsibility, and review. |
|
||||
| **Auto Discussion V4** | Agents propose independently, discuss, request peer contributions, carry out authorized work, and check deliverables. A delivery owner judges progress and requests user decisions when needed. Native sessions continue when supported. | Multi-step collaboration with less manual coordination. |
|
||||
|
||||
Participants are selected by the user. Automatic selection from a larger roster is outside the current preview.
|
||||
|
||||
## The review record
|
||||
## Revisit the process and continue the work
|
||||
|
||||
Meldwork treats collaboration as a decision process rather than a stream of chat messages:
|
||||
Meldwork keeps task context, Agent replies, run state, and deliverables locally so you can see how work progressed, where it stalled, and what needs attention next. The Run Ledger preserves phase, participant, attempt, artifact, recovery, and Human Gate state. Diagnostic tool output and sensitive runtime details stay behind the Electron main-process boundary.
|
||||
|
||||
- **Case** defines the question, scope, context, and permissions.
|
||||
- **Finding** records an Agent's claim, observation, or proposed action.
|
||||
- **Evidence** links the finding to a response, artifact, file, knowledge result, or other bounded reference.
|
||||
- **Decision** records what the evidence supports, what remains uncertain, and which path is selected.
|
||||
- **Disposition** records whether the result was accepted, revised, rejected, superseded, or left unresolved.
|
||||
|
||||
The Run Ledger preserves phase, participant, attempt, receipt, artifact, recovery, and Human Gate state. Diagnostic tool output and sensitive runtime details stay behind the Electron main-process boundary.
|
||||
These records help you inspect collaboration; they do not establish that every claim has been fact-checked. Peer review does not guarantee correctness, and important deliverables still need acceptance checks appropriate to their use.
|
||||
|
||||
## General Agent catalog
|
||||
|
||||
@@ -66,20 +60,21 @@ Meldwork detects and invokes compatible locally installed Agent commands. The ca
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/codex.svg" alt="Codex logo" width="32" height="32"><br><strong>Codex</strong></td>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/hermes.png" alt="Hermes logo" width="32" height="32"><br><strong>Hermes</strong></td>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/openclaw-transparent.png" alt="OpenClaw logo" width="32" height="32"><br><strong>OpenClaw</strong></td>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/workbuddy.png" alt="WorkBuddy logo" width="32" height="32"><br><strong>WorkBuddy</strong></td>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/pi.svg" alt="Pi Agent logo" width="32" height="32"><br><strong>Pi Agent</strong></td>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/kimi.png" alt="Kimi Code logo" width="32" height="32"><br><strong>Kimi Code</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/codex.svg" alt="Codex logo" width="32" height="32"><br><strong>Codex</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/hermes.png" alt="Hermes logo" width="32" height="32"><br><strong>Hermes</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/openclaw-transparent.png" alt="OpenClaw logo" width="32" height="32"><br><strong>OpenClaw</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/workbuddy.png" alt="WorkBuddy logo" width="32" height="32"><br><strong>WorkBuddy</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/zcode.png" alt="Zcode logo" width="32" height="32"><br><strong>Zcode</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/pi.svg" alt="Pi Agent logo" width="32" height="32"><br><strong>Pi Agent</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/kimi.png" alt="Kimi Code logo" width="32" height="32"><br><strong>Kimi Code</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/mimo.svg" alt="MiMo Code logo" width="32" height="32"><br><strong>MiMo Code</strong></td>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/claude.png" alt="Claude Code logo" width="32" height="32"><br><strong>Claude Code</strong></td>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/gemini.svg" alt="Gemini CLI logo" width="32" height="32"><br><strong>Gemini CLI</strong></td>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/opencode.svg" alt="OpenCode logo" width="32" height="32"><br><strong>OpenCode</strong></td>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/qwen.svg" alt="Qwen Code logo" width="32" height="32"><br><strong>Qwen Code</strong></td>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/opencodereview.svg" alt="OpenCodeReview logo" width="32" height="32"><br><strong>OpenCodeReview</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/mimo.svg" alt="MiMo Code logo" width="32" height="32"><br><strong>MiMo Code</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/claude.png" alt="Claude Code logo" width="32" height="32"><br><strong>Claude Code</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/gemini.svg" alt="Gemini CLI logo" width="32" height="32"><br><strong>Gemini CLI</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/opencode.svg" alt="OpenCode logo" width="32" height="32"><br><strong>OpenCode</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/qwen.svg" alt="Qwen Code logo" width="32" height="32"><br><strong>Qwen Code</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/opencodereview.svg" alt="OpenCodeReview logo" width="32" height="32"><br><strong>OpenCodeReview</strong></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -91,9 +86,9 @@ Meldwork runs as a local Electron application. Conversations, group configuratio
|
||||
|
||||
Selected Agents may send prompts, attachments, or Skills to the model provider configured in their own CLI. Knowledge access is explicit and bounded: the current implementation supports local Obsidian retrieval and CLI-owned Feishu or DingTalk access modes. Local-first describes where Meldwork stores and coordinates work; it does not promise that an Agent's model provider is offline.
|
||||
|
||||
## Download V1.0.5
|
||||
## Download V1.0.8
|
||||
|
||||
For Apple silicon macOS, download the [Meldwork V1.0.5 prerelease](https://github.com/Ryder-MHumble/Meldwork/releases/tag/Meldwork-V1.0.5) and choose [`Meldwork-0.1.5-arm64.dmg`](https://github.com/Ryder-MHumble/Meldwork/releases/download/Meldwork-V1.0.5/Meldwork-0.1.5-arm64.dmg). The release also includes a ZIP archive and SHA-256 manifest. Install at least one supported local Agent CLI before opening the app.
|
||||
For Apple silicon macOS, download the [Meldwork V1.0.8 prerelease](https://github.com/Ryder-Sun/Meldwork/releases/tag/Meldwork-V1.0.8) and choose [`Meldwork-1.0.8-arm64.dmg`](https://github.com/Ryder-Sun/Meldwork/releases/download/Meldwork-V1.0.8/Meldwork-1.0.8-arm64.dmg). The release also includes a ZIP archive and SHA-256 manifest. Install at least one supported local Agent CLI before opening the app.
|
||||
|
||||
The preview artifacts are ad-hoc signed and not notarized with an Apple Developer ID. macOS may require **Open Anyway** under **System Settings → Privacy & Security** on first launch.
|
||||
|
||||
@@ -116,11 +111,13 @@ npm --prefix frontend run build:desktop
|
||||
npm --prefix desktop test
|
||||
```
|
||||
|
||||
## V1.0.5
|
||||
## V1.0.8
|
||||
|
||||
V1.0.5 improves local Agent readiness and recovery, preserves healthy participants when another Agent fails, keeps completed and stopped work visible after restart, and tightens group execution and output handling. It also updates the workspace preference flow, sidebar and titlebar details, and the Pi capability probe contract.
|
||||
V1.0.8 keeps Kimi read-only queries in manual approval mode instead of forcing a planning cycle, preserves MiMo's final text chunks, forwards supported native CLI configuration, and retains the selected Auto Discussion mode after sending. Repetitive native output is cancelled with partial replies preserved. Zcode remains before Pi Agent with its official logo and native default configuration; WorkBuddy can reuse a configured desktop model selected in Meldwork.
|
||||
|
||||
Verification for this prerelease includes 343/343 frontend tests, 1,562/1,562 desktop tests, six deterministic evaluation cases with 18 results, web and desktop builds, packaging, ZIP integrity, deep code-signature verification, and packaged macOS acceptance for group execution, cancellation, and restart recovery. The artifacts remain ad-hoc signed and unnotarized; live behavior still depends on the installed Agent CLI version, authentication, Provider, and capabilities.
|
||||
Real CLI acceptance covered all 13 installed Agents. A five-Agent unlimited discussion completed all 14 native calls through an isolated Magpie hotfix. The installed Magpie version can rewrite `stop` into `tool_calls` when `tool_calls` is null, causing OpenCode, MiMo, and Hermes to continue indefinitely; this Meldwork release does not install or restart Magpie. OpenClaw, Zcode, Gemini, Qwen, and OpenCodeReview also had native configuration or authentication failures in this audit. Detecting an installed CLI does not certify a working model connection. Regression validation includes 1,655 desktop tests, 370 frontend tests, web and desktop builds, and package validation. Unlimited discussion retains stalled-process detection and write permissions remain opt-in.
|
||||
|
||||
Electron acceptance with the same isolated gateway also completed a separate five-Agent unlimited discussion: 19 native calls, no failed attempts or retries, about 12 minutes. Auto Discussion and No round limit remained selected after sending, completion, and leaving and reopening the group. This verifies the workflow, not a response-time guarantee or a repair to the user's installed gateway.
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -131,4 +128,4 @@ Verification for this prerelease includes 343/343 frontend tests, 1,562/1,562 de
|
||||
|
||||
## License
|
||||
|
||||
Meldwork is source-available under the [Meldwork Non-Commercial Source License 1.0](LICENSE). You may inspect, run, modify, and share it for non-commercial purposes. Commercial use requires prior written permission; see [COMMERCIAL_USE.md](COMMERCIAL_USE.md).
|
||||
Meldwork is open source under the [Apache License 2.0](LICENSE). Commercial use, private use, modification, and redistribution are permitted under the license terms. No separate commercial authorization or license fee is required. Apache-2.0 does not grant trademark rights; see [COMMERCIAL_USE.md](COMMERCIAL_USE.md).
|
||||
+36
-39
@@ -6,37 +6,37 @@
|
||||
<a href="README.md">English</a> · <strong>简体中文</strong>
|
||||
</p>
|
||||
|
||||
# Meldwork:本地优先的 General Agent 工作空间
|
||||
# Meldwork:让 Agent 自主协作推进任务
|
||||
|
||||
**把多个 General Agent 组织成一个可复核的工作小组。Meldwork 为每个任务建立共享上下文,让选定的 Agent 独立调查或分轮讨论,并在采用结果前保留完整的证据和人工决策。**
|
||||
**提出目标,让 Agent 们协作完成。Meldwork 把你已有的 Agent 组织成一个工作小组,共享上下文、讨论方案、协作执行并相互检查,减少你在它们之间转述、催办和整合结果的工作。**
|
||||
|
||||
Meldwork 是一个本地优先的 Electron 工作空间,用来连接你已经在电脑上使用的 Agent 工具。它支持直接会话、独立并发回复,以及包含提案、质询、职责协商、分工执行、整合和复核的 Auto Discussion V4。Agent 可以处理文字、文件、图片、音视频、Skill 和选定的知识源,因此同一个工作空间可以覆盖研究、分析、写作、规划、评审和实施。
|
||||
Meldwork 是一个本地优先的多 Agent 协作工作空间,用来连接你已经在电脑上使用的 Agent 工具。你选定参与者、目标、上下文和权限后,自动讨论中的 Agent 可以接续彼此的工作,请求同伴补充或检查,并在授权范围内推进任务。它也支持单个 Agent 的直接会话和多个 Agent 的独立并发回复,覆盖研究、分析、写作、规划、评审和实施。
|
||||
|
||||
产品围绕一条可持续复核的记录组织工作:**Case → Finding → Evidence → Decision → Disposition**。Meldwork 将任务快照、Agent 贡献、运行状态、产物、人工审批门和采用决定保存在同一个本地工作单元中。工作区写入默认受控,并由用户明确授权。
|
||||
你无需安排每一轮发言,但仍负责目标、权限和关键决策。遇到信息缺失、权限请求或需要取舍的问题时,任务可能等待你处理。工作区写入需要明确授权;协作过程、运行状态和产物保存在本地,方便回看和继续工作。
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Ryder-MHumble/Meldwork/releases/download/Meldwork-V1.0.5/Meldwork-0.1.5-arm64.dmg"><strong>下载 Meldwork V1.0.5 Apple 芯片 macOS 版</strong></a>
|
||||
<a href="https://github.com/Ryder-Sun/Meldwork/releases/download/Meldwork-V1.0.8/Meldwork-1.0.8-arm64.dmg"><strong>下载 Meldwork V1.0.8 Apple 芯片 macOS 版</strong></a>
|
||||
· <a href="architecture.md">架构</a>
|
||||
· <a href="LICENSE">许可证</a>
|
||||
</p>
|
||||
|
||||
## Meldwork 适合什么工作
|
||||
|
||||
当一个决定需要多个视角,并且需要留下清晰的采用依据时,Meldwork 更适合:
|
||||
当一个任务需要多个 Agent 共同推进,而你不想反复传递上下文、协调下一步时,Meldwork 更适合:
|
||||
|
||||
- 研究与信息综合:结合本地 Agent 工具和明确选择的知识源;
|
||||
- 产品、市场、运营和技术分析;
|
||||
- 写作、规划、评审和结构化文档工作;
|
||||
- 处理本地文档、图片、音频、视频、PDF、代码和配置文件的多模态工作;
|
||||
- 需要保留发现、证据、权限、运行轨迹和最终采用决定的实施工作。
|
||||
- 在授权工作目录内执行、由其他 Agent 检查成果并继续修改的实施工作。
|
||||
|
||||
## 一个任务如何在 Meldwork 中推进
|
||||
|
||||
1. **选择**本 Case 的 Agent 和参与者。
|
||||
1. **选择**一起处理任务的 Agent。
|
||||
2. **限定**目标、工作目录、上下文、附件、Skill、知识源和权限。
|
||||
3. **运行**直接会话、并发回复或 Auto Discussion V4。
|
||||
4. **复核**发现、证据、产物、运行轨迹和人工审批门。
|
||||
5. **采用**批准的结果,或保留为待解决状态继续处理。
|
||||
3. **协作**:启动 Auto Discussion,让 Agent 讨论方案、接续执行并检查成果;也可选择直接会话或并发回复。
|
||||
4. **决策**:在需要补充信息、授权或取舍时介入。
|
||||
5. **查看结果**:检查交付物,必要时提出修改,让工作继续推进。
|
||||
|
||||
## 协作模式
|
||||
|
||||
@@ -44,21 +44,15 @@ Meldwork 是一个本地优先的 Electron 工作空间,用来连接你已经
|
||||
| --- | --- | --- |
|
||||
| **直接会话** | 一个已选 Agent 在适配器支持时保持对话和原生会话。 | 聚焦研究、写作、分析或执行。 |
|
||||
| **并发回复** | 已选 Agent 收到同一份冻结任务快照,并按稳定顺序返回独立回复。 | 在选择方案前比较不同视角。 |
|
||||
| **Auto Discussion V4** | Agent 先提出方案,再进行质询、职责协商、分工执行、候选结果整合和独立复核;支持时会在多轮中保持原生会话。 | 需要讨论、分工和复核的多步骤工作。 |
|
||||
| **Auto Discussion V4** | Agent 独立提出方案后,接续讨论、请求同伴贡献、在授权范围内执行并检查成果。交付负责人判断任务进度,必要时请求用户决策;支持时保持原生会话。 | 希望减少人工协调的多步骤协作任务。 |
|
||||
|
||||
参与者始终由用户选择。从更大候选池自动选人组队不属于当前预览版。
|
||||
|
||||
## 复核记录
|
||||
## 过程可回看,工作可继续
|
||||
|
||||
Meldwork 将协作组织成一套决策过程,而不是一串聊天消息:
|
||||
Meldwork 在本地保留任务上下文、Agent 回复、运行状态和产物,方便了解任务如何推进、哪里遇到阻塞,以及接下来需要处理什么。Run Ledger 保留阶段、参与者、尝试、产物、恢复状态和人工审批门;诊断工具输出与敏感运行信息留在 Electron 主进程边界内。
|
||||
|
||||
- **Case**:定义问题、范围、上下文和权限;
|
||||
- **Finding**:记录 Agent 的判断、观察或行动建议;
|
||||
- **Evidence**:将 Finding 连接到回复、产物、文件、知识结果或其他受约束的依据;
|
||||
- **Decision**:记录证据支持的结论、未决问题和选定路径;
|
||||
- **Disposition**:记录结果已接受、修订、拒绝、被替代或仍未解决。
|
||||
|
||||
Run Ledger 会保留阶段、参与者、尝试、回执、产物、恢复状态和人工审批门。诊断工具输出与敏感运行信息留在 Electron 主进程边界内。
|
||||
过程记录帮助检查协作情况,不代表每条结论都经过事实核验。Agent 的相互检查也不能保证结果正确,重要交付物仍需按实际用途验收。
|
||||
|
||||
## General Agent 目录
|
||||
|
||||
@@ -66,20 +60,21 @@ Run Ledger 会保留阶段、参与者、尝试、回执、产物、恢复状态
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/codex.svg" alt="Codex logo" width="32" height="32"><br><strong>Codex</strong></td>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/hermes.png" alt="Hermes logo" width="32" height="32"><br><strong>Hermes</strong></td>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/openclaw-transparent.png" alt="OpenClaw logo" width="32" height="32"><br><strong>OpenClaw</strong></td>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/workbuddy.png" alt="WorkBuddy logo" width="32" height="32"><br><strong>WorkBuddy</strong></td>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/pi.svg" alt="Pi Agent logo" width="32" height="32"><br><strong>Pi Agent</strong></td>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/kimi.png" alt="Kimi Code logo" width="32" height="32"><br><strong>Kimi Code</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/codex.svg" alt="Codex logo" width="32" height="32"><br><strong>Codex</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/hermes.png" alt="Hermes logo" width="32" height="32"><br><strong>Hermes</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/openclaw-transparent.png" alt="OpenClaw logo" width="32" height="32"><br><strong>OpenClaw</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/workbuddy.png" alt="WorkBuddy logo" width="32" height="32"><br><strong>WorkBuddy</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/zcode.png" alt="Zcode logo" width="32" height="32"><br><strong>Zcode</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/pi.svg" alt="Pi Agent logo" width="32" height="32"><br><strong>Pi Agent</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/kimi.png" alt="Kimi Code logo" width="32" height="32"><br><strong>Kimi Code</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/mimo.svg" alt="MiMo Code logo" width="32" height="32"><br><strong>MiMo Code</strong></td>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/claude.png" alt="Claude Code logo" width="32" height="32"><br><strong>Claude Code</strong></td>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/gemini.svg" alt="Gemini CLI logo" width="32" height="32"><br><strong>Gemini CLI</strong></td>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/opencode.svg" alt="OpenCode logo" width="32" height="32"><br><strong>OpenCode</strong></td>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/qwen.svg" alt="Qwen Code logo" width="32" height="32"><br><strong>Qwen Code</strong></td>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/opencodereview.svg" alt="OpenCodeReview logo" width="32" height="32"><br><strong>OpenCodeReview</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/mimo.svg" alt="MiMo Code logo" width="32" height="32"><br><strong>MiMo Code</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/claude.png" alt="Claude Code logo" width="32" height="32"><br><strong>Claude Code</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/gemini.svg" alt="Gemini CLI logo" width="32" height="32"><br><strong>Gemini CLI</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/opencode.svg" alt="OpenCode logo" width="32" height="32"><br><strong>OpenCode</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/qwen.svg" alt="Qwen Code logo" width="32" height="32"><br><strong>Qwen Code</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/opencodereview.svg" alt="OpenCodeReview logo" width="32" height="32"><br><strong>OpenCodeReview</strong></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -91,9 +86,9 @@ Meldwork 作为本地 Electron 应用运行。对话、群组配置、运行记
|
||||
|
||||
选定的 Agent 仍可能把 Prompt、附件或 Skill 发送到其自身 CLI 凭据对应的模型服务商。知识访问必须明确选择并受边界约束:当前实现支持本地 Obsidian 检索,以及由本地 CLI 管理的飞书或钉钉访问模式。Local-first 描述的是 Meldwork 的数据和协作位置,不代表 Agent 使用的模型服务商一定离线。
|
||||
|
||||
## 下载 V1.0.5
|
||||
## 下载 V1.0.8
|
||||
|
||||
Apple 芯片 macOS 用户请前往 [Meldwork V1.0.5 预发布页](https://github.com/Ryder-MHumble/Meldwork/releases/tag/Meldwork-V1.0.5),下载 [`Meldwork-0.1.5-arm64.dmg`](https://github.com/Ryder-MHumble/Meldwork/releases/download/Meldwork-V1.0.5/Meldwork-0.1.5-arm64.dmg)。发布页同时提供 ZIP 压缩包和 SHA-256 校验文件。启动应用前,请至少安装一个受支持的本地 Agent CLI。
|
||||
Apple 芯片 macOS 用户请前往 [Meldwork V1.0.8 预发布页](https://github.com/Ryder-Sun/Meldwork/releases/tag/Meldwork-V1.0.8),下载 [`Meldwork-1.0.8-arm64.dmg`](https://github.com/Ryder-Sun/Meldwork/releases/download/Meldwork-V1.0.8/Meldwork-1.0.8-arm64.dmg)。发布页同时提供 ZIP 压缩包和 SHA-256 校验文件。启动应用前,请至少安装一个受支持的本地 Agent CLI。
|
||||
|
||||
该预览版安装包使用 ad-hoc 临时签名,未使用 Apple Developer ID 签名,也未进行公证。macOS 首次启动时可能需要在“系统设置 → 隐私与安全性”中选择“仍要打开 / Open Anyway”。
|
||||
|
||||
@@ -116,11 +111,13 @@ npm --prefix frontend run build:desktop
|
||||
npm --prefix desktop test
|
||||
```
|
||||
|
||||
## V1.0.5
|
||||
## V1.0.8
|
||||
|
||||
V1.0.5 改进了本地 Agent 的就绪检测与恢复,在单个 Agent 失败时保留健康参与者,重启后继续显示已完成和已停止的工作,并加强了群组执行与结果导入。同时更新了工作区偏好、侧栏和标题栏细节,以及 Pi 能力探测契约。
|
||||
V1.0.8 将 Kimi 只读问答改为手动权限模式,避免强制进入计划流程;修复 MiMo 末尾文本丢失、原生 CLI 配置变量遗漏,以及发送后 Auto Discussion 模式重置。原生输出持续重复时会取消调用并保留已输出内容。Zcode 保持官方标志和 Pi Agent 前的位置,沿用原生默认配置;WorkBuddy 可以复用在 Meldwork 中选定的桌面已配置模型。
|
||||
|
||||
本预览版已完成 343/343 前端测试、1,562/1,562 桌面端测试、6 个确定性评估场景共 18 条结果验证,并通过 Web 与桌面构建、打包、ZIP 完整性、深度代码签名校验,以及 Apple 芯片 macOS 上的群组执行、取消和重启恢复验收。安装包仍为 ad-hoc 临时签名且未公证;实际运行结果仍取决于本机 Agent CLI 版本、鉴权、Provider 和能力。
|
||||
本轮对全部 13 个已安装 CLI 进行了真实调用。使用独立 Magpie 热修网关的五 Agent 无限轮讨论中,14 次原生调用全部完成。当前安装的 Magpie 可能把 `tool_calls:null` 误判为真实工具调用,将 `stop` 改写为 `tool_calls`,导致 OpenCode、MiMo 和 Hermes 持续续写;本次 Meldwork 发布不会安装或重启 Magpie。OpenClaw、Zcode、Gemini、Qwen 和 OpenCodeReview 在本机另有原生配置或认证故障,检测到安装不代表模型连接已可用。回归验证包括桌面测试 1,655 项、前端测试 370 项、Web 与桌面构建及打包验证。无限轮讨论保留进程停滞检测,工作区写入仍需明确授权。
|
||||
|
||||
Electron 实机界面另完成一轮五 Agent 无限轮讨论:19 次原生调用全部成功,无失败或自动重试,用时约 12 分钟。发送、完成、切回首页再返回群聊后,自动讨论与不限轮数均保持选中。该验收同样使用独立热修网关,证明流程可完成,不构成响应时长保证,也不代表用户现有网关已修复。
|
||||
|
||||
## 文档
|
||||
|
||||
@@ -131,4 +128,4 @@ V1.0.5 改进了本地 Agent 的就绪检测与恢复,在单个 Agent 失败
|
||||
|
||||
## 许可证
|
||||
|
||||
Meldwork 采用 [Meldwork 非商用源码许可 1.0](LICENSE)。你可以出于非商业目的查看、运行、修改和分享。商业使用需要事先书面许可,详见 [COMMERCIAL_USE.md](COMMERCIAL_USE.md)。
|
||||
Meldwork 以 [Apache License 2.0](LICENSE) 开源。在许可证条款范围内,可以商用、私用、修改和再分发。商用无需另行授权或支付许可费。Apache-2.0 不授予商标权;详见 [COMMERCIAL_USE.md](COMMERCIAL_USE.md)。
|
||||
+18
-20
@@ -13,12 +13,15 @@ API Key 或任意 Shell 执行能力。
|
||||
- Hermes
|
||||
- OpenClaw
|
||||
- WorkBuddy
|
||||
- Zcode
|
||||
- Pi Agent
|
||||
- Kimi Code
|
||||
- MiMo Code
|
||||
- Claude Code
|
||||
- Gemini CLI
|
||||
- OpenCode
|
||||
- Qwen Code
|
||||
- OpenCodeReview
|
||||
|
||||
Agent 管理面板提供经过白名单约束的安装入口。安装命令和下载地址由主进程固定,不会根据页面
|
||||
输入执行任意命令。内置 Agent 通过各自支持的原生权限模式或 Meldwork 托管工具策略执行;无法
|
||||
@@ -28,7 +31,7 @@ Agent 管理面板提供经过白名单约束的安装入口。安装命令和
|
||||
|
||||
## 首次启动与本地 Agent 检测
|
||||
|
||||
V1.0.3 GitHub 预发布使用 ad-hoc 临时签名,不使用 Apple Developer ID,也不提交 Apple 公证。
|
||||
V1.0.7 GitHub 预发布使用 ad-hoc 临时签名,不使用 Apple Developer ID,也不提交 Apple 公证。
|
||||
如果 macOS 首次启动时拦截候选应用,请打开
|
||||
“系统设置 → 隐私与安全性”,找到 Meldwork 的拦截提示,点击“仍要打开 / Open Anyway”,
|
||||
再确认启动。
|
||||
@@ -66,34 +69,29 @@ Agent 原生支持的图片会按其数量限制直接传递。其他文件,
|
||||
|
||||
## Provider 配置
|
||||
|
||||
用户可以在客户端内填写一个 OpenAI-compatible Provider:
|
||||
|
||||
```text
|
||||
provider: Provider 显示名称
|
||||
baseUrl: HTTPS 地址,或本机 loopback HTTP 地址
|
||||
model: 模型名称
|
||||
apiKey: API Key
|
||||
```
|
||||
|
||||
API Key 仅通过 Electron `safeStorage` 加密保存到本机;操作系统安全存储不可用时拒绝落盘。
|
||||
Provider 不设品牌、服务地址或模型默认值,也不会从远端自动获取配置。配置后可供 Hermes、
|
||||
OpenClaw、WorkBuddy、Qwen 和 OpenCodeReview 使用;其他 Agent 沿用各自的本机鉴权和 Provider 配置。
|
||||
Agent 使用各自本机的原生鉴权、Provider 和默认模型配置。Meldwork 不要求统一 Provider,
|
||||
也不会将一个 Agent 的密钥注入其他 Agent。WorkBuddy 桌面账号的加密登录不能直接供独立
|
||||
CLI 解锁;可在 Meldwork 中选择桌面配置里已有且已配置鉴权的模型。
|
||||
Meldwork 仅保存模型标识,运行时由主进程读取原生配置,不向渲染层返回密钥。
|
||||
Zcode 沿用默认配置;若其默认 Provider 缺少 API Key,需要先修复本机原生配置。
|
||||
|
||||
## 本地 CLI 支持矩阵
|
||||
|
||||
| Agent | 检测命令 | macOS 安装 | Windows 安装 | 用户 Provider |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Codex | `codex` | npm:`@openai/codex@latest` | npm:`@openai/codex@latest` | 不注入;沿用 Codex 本机配置 |
|
||||
| Hermes | `hermes` | 官方 `install.sh` | 官方 `install.ps1` | 支持 |
|
||||
| OpenClaw | `openclaw` | npm:`openclaw@latest` | npm:`openclaw@latest` | 支持,使用客户端隔离的托管配置 |
|
||||
| WorkBuddy | `codebuddy` | npm:`@tencent-ai/[email protected]` | npm:`@tencent-ai/[email protected]` | 实验性支持 |
|
||||
| Hermes | `hermes` | 官方 `install.sh` | 官方 `install.ps1` | 沿用本机配置 |
|
||||
| OpenClaw | `openclaw` | npm:`openclaw@latest` | npm:`openclaw@latest` | 沿用本机配置 |
|
||||
| WorkBuddy | `codebuddy` | npm:`@tencent-ai/[email protected]` | npm:`@tencent-ai/[email protected]` | 可选择桌面已配置模型 |
|
||||
| Zcode | `zcode` | 官方 CLI 安装 | 官方 CLI 安装 | 沿用默认配置 |
|
||||
| Pi Agent | `pi` | npm:`@mariozechner/pi-coding-agent` | npm:`@mariozechner/pi-coding-agent` | 沿用本机配置 |
|
||||
| Kimi Code | `kimi` | 官方 `install.sh` | 官方 `install.ps1` | 不注入;沿用 Kimi 本机配置 |
|
||||
| MiMo Code | `mimo` | npm:`@mimo-ai/cli@latest` | npm:`@mimo-ai/cli@latest` | 不注入;沿用 MiMo 本机配置,Meldwork 固定使用只读 `plan` Agent |
|
||||
| MiMo Code | `mimo` | npm:`@mimo-ai/cli@latest` | npm:`@mimo-ai/cli@latest` | 不注入;沿用 MiMo 本机配置,按授权使用 `plan` / `build` 模式 |
|
||||
| Claude Code | `claude` | npm:`@anthropic-ai/claude-code@latest` | npm:`@anthropic-ai/claude-code@latest` | 不注入;沿用 Claude 本机配置 |
|
||||
| Gemini CLI | `gemini` | npm:`@google/gemini-cli@latest` | npm:`@google/gemini-cli@latest` | 不注入;沿用 Gemini 本机配置 |
|
||||
| OpenCode | `opencode` | npm:`opencode-ai@latest` | npm:`opencode-ai@latest` | 不注入;沿用 OpenCode 本机配置 |
|
||||
| Qwen Code | `qwen` | npm:`@qwen-code/qwen-code@latest` | npm:`@qwen-code/qwen-code@latest` | 支持 |
|
||||
| OpenCodeReview | `ocr` | npm:`@alibaba-group/open-code-review@latest` | npm:`@alibaba-group/open-code-review@latest` | 支持 |
|
||||
| Qwen Code | `qwen` | npm:`@qwen-code/qwen-code@latest` | npm:`@qwen-code/qwen-code@latest` | 沿用本机配置 |
|
||||
| OpenCodeReview | `ocr` | npm:`@alibaba-group/open-code-review@latest` | npm:`@alibaba-group/open-code-review@latest` | 沿用本机配置 |
|
||||
|
||||
macOS 会在当前 `PATH` 之外搜索 Volta、pnpm、fnm、asdf、mise、bun、
|
||||
`~/.local/bin`、`~/.kimi-code/bin`、`~/.mimocode/bin`、Homebrew 等常见目录。Windows 会额外搜索用户 npm、
|
||||
@@ -137,7 +135,7 @@ npm run dist
|
||||
npm run dist:public
|
||||
```
|
||||
|
||||
`pack` 用于生成未打包应用并验证 Electron 产物;`dist` 生成 V1.0.3 GitHub 预发布候选使用的 ad-hoc 签名 DMG 和 ZIP。这些产物不具备 Developer ID 信任链且未公证,不能声称通过 Gatekeeper。
|
||||
`pack` 用于生成未打包应用并验证 Electron 产物;`dist` 生成 V1.0.7 GitHub 预发布候选使用的 ad-hoc 签名 DMG 和 ZIP。这些产物不具备 Developer ID 信任链且未公证,不能声称通过 Gatekeeper。
|
||||
|
||||
`dist:public` 是未来正式签名公开发行的专用命令:它要求
|
||||
Developer ID 证书和完整公证凭据,强制代码签名,在签名后校验永久 Bundle ID、Team ID 与
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "meldwork-desktop",
|
||||
"version": "0.1.6",
|
||||
"version": "1.0.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meldwork-desktop",
|
||||
"version": "0.1.6",
|
||||
"version": "1.0.8",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.23.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "meldwork-desktop",
|
||||
"version": "0.1.6",
|
||||
"version": "1.0.8",
|
||||
"private": true,
|
||||
"description": "A local-first desktop workspace for multi-Agent collaboration",
|
||||
"author": "Ryder-MHumble",
|
||||
|
||||
@@ -5,6 +5,7 @@ const AGENT_VERSION_IDENTITIES = Object.freeze({
|
||||
hermes: [/\bhermes(?:\s+agent)?\b/i],
|
||||
openclaw: [/\bopenclaw\b/i],
|
||||
workbuddy: [/\b(?:workbuddy|codebuddy)\b/i],
|
||||
zcode: [/\bzcode\b/i],
|
||||
pi: [/\bpi(?:\s+agent)?\b/i],
|
||||
kimi: [/\bkimi(?:\s+code)?\b/i],
|
||||
mimo: [/\bmimo(?:\s+code)?\b/i, /\bmimocode\b/i],
|
||||
@@ -19,6 +20,7 @@ const AGENT_EXECUTABLE_NAMES = Object.freeze({
|
||||
hermes: ['hermes'],
|
||||
openclaw: ['openclaw'],
|
||||
workbuddy: ['codebuddy'],
|
||||
zcode: ['zcode'],
|
||||
pi: ['pi', 'pi-agent', 'piagent'],
|
||||
kimi: ['kimi'],
|
||||
mimo: ['mimo'],
|
||||
@@ -56,6 +58,9 @@ const AGENT_COMPATIBILITY = Object.freeze({
|
||||
'json', 'plan', 'acceptEdits',
|
||||
]),
|
||||
]),
|
||||
zcode: exactProfile('0.16.1', [
|
||||
probe('zcode-prompt', ['--help'], ['--prompt', '--json', '--mode', '--resume', 'plan', 'edit']),
|
||||
]),
|
||||
pi: exactProfile('0.84.2', [
|
||||
probe('pi-cli', ['--help'], [
|
||||
'--mode', '--print', '--session', '--tools', '--approve', '--no-approve', 'json',
|
||||
|
||||
@@ -8,6 +8,12 @@ function sha256(value) {
|
||||
// Tool kinds an Agent may use without approval during a read-only turn.
|
||||
const READ_ONLY_SAFE_TOOL_KINDS = new Set(['read', 'search', 'fetch', 'think'])
|
||||
|
||||
function terminalPlanRejection(options = []) {
|
||||
// Kimi's first reject option is "Revise", which loops without feedback.
|
||||
return options.find(option => option?.optionId === 'plan_reject_and_exit'
|
||||
&& ['reject_once', 'reject_always'].includes(option.kind))
|
||||
}
|
||||
|
||||
// Automatic answer to an Agent's permission request when the run is not
|
||||
// waiting for a person. Returns the response to send, `null` when the request
|
||||
// is refused but the Agent offered nothing to refuse with, or `undefined`
|
||||
@@ -19,7 +25,8 @@ const READ_ONLY_SAFE_TOOL_KINDS = new Set(['read', 'search', 'fetch', 'think'])
|
||||
function automaticPermissionResponse(request, { allowWrite }) {
|
||||
const options = Array.isArray(request?.options) ? request.options : []
|
||||
if (!allowWrite && !READ_ONLY_SAFE_TOOL_KINDS.has(request?.toolCall?.kind)) {
|
||||
const rejection = options.find(candidate => candidate?.kind === 'reject_once')
|
||||
const rejection = terminalPlanRejection(options)
|
||||
|| options.find(candidate => candidate?.kind === 'reject_once')
|
||||
|| options.find(candidate => candidate?.kind === 'reject_always')
|
||||
return rejection?.optionId ? { status: 'rejected', optionId: rejection.optionId } : null
|
||||
}
|
||||
@@ -69,6 +76,7 @@ function assertResumedPermissionBinding(resumed, currentBinding, sessionRef) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
terminalPlanRejection,
|
||||
READ_ONLY_SAFE_TOOL_KINDS,
|
||||
assertResumedPermissionBinding,
|
||||
automaticPermissionResponse,
|
||||
|
||||
@@ -80,6 +80,10 @@ const AGENT_RUNTIME_CAPABILITIES = Object.freeze({
|
||||
domains: ['general', 'software-development', 'document-production'],
|
||||
toolClasses: ['filesystem', 'shell'],
|
||||
}),
|
||||
zcode: generalProfile({
|
||||
domains: ['general', 'software-development', 'research'],
|
||||
toolClasses: ['filesystem', 'shell'],
|
||||
}),
|
||||
pi: generalProfile({
|
||||
domains: ['general', 'software-development'],
|
||||
toolClasses: ['filesystem', 'shell'],
|
||||
@@ -237,7 +241,7 @@ function authFailureCode(detail) {
|
||||
function failureCategory(code) {
|
||||
const value = String(code || '')
|
||||
if (value === 'LOCAL_AGENT_SESSION_INVALID') return 'session'
|
||||
if (value === 'LOCAL_AGENT_AUTH_REQUIRED') return 'authentication'
|
||||
if (value === 'LOCAL_AGENT_AUTH_REQUIRED' || value === 'LOCAL_WORKBUDDY_PROVIDER_AUTH') return 'authentication'
|
||||
if (value === 'LOCAL_AGENT_ACCESS_DENIED') return 'provider'
|
||||
if (value === 'LOCAL_AGENT_TIMEOUT') return 'timeout'
|
||||
if (value === 'LOCAL_AGENT_EXECUTION_STOPPED') return 'cancellation'
|
||||
|
||||
@@ -23,12 +23,16 @@ const {
|
||||
} = require('./cli-process-support.cjs')
|
||||
const { createAcpOutboundPayload } = require('../../collaboration/outbound-payload.cjs')
|
||||
const { redactSecrets } = require('../../security/secret-redaction.cjs')
|
||||
const { createOutputRepetitionGuard } = require('./cli-output-repetition.cjs')
|
||||
const { terminalPlanRejection } = require('../agent-permission-policy.cjs')
|
||||
|
||||
const ACP_CANCEL_GRACE_MS = 250
|
||||
// After a stop or timeout the Agent gets this long to answer the cancelled
|
||||
// prompt. A clean cancellation keeps the long-lived process and its native
|
||||
// session; only an Agent that ignores cancel is torn down.
|
||||
const ACP_CANCEL_SETTLE_MS = 3000
|
||||
// MiMo can flush the last delta of a message just after its prompt response.
|
||||
const MIMO_TERMINAL_DRAIN_MS = 50
|
||||
const ACP_INITIALIZE_TIMEOUT_MS = 60 * 1000
|
||||
const ACP_STDERR_TAIL_BYTES = 64 * 1024
|
||||
// Process identity must not change when only volatile shell values change.
|
||||
@@ -355,7 +359,7 @@ function boundedAcpStream(
|
||||
}
|
||||
|
||||
function permissionRejection(options) {
|
||||
return (options || []).find(option => (
|
||||
return terminalPlanRejection(options || []) || (options || []).find(option => (
|
||||
['reject_once', 'reject_always'].includes(option.kind)
|
||||
|| /reject|deny/i.test(`${option.optionId || ''} ${option.name || ''}`)
|
||||
))
|
||||
@@ -621,7 +625,7 @@ function runtimeSignature(agent, workdir, spec, profile, childEnv) {
|
||||
workdir,
|
||||
command: spec.command,
|
||||
args: spec.args,
|
||||
// The session mode (read-only plan versus write) is applied on every turn
|
||||
// The native session mode is applied on every turn
|
||||
// with session/set_mode, so it does not identify a process. Anything that
|
||||
// really needs a separate process (a permission policy in the environment)
|
||||
// is already part of the environment below.
|
||||
@@ -746,6 +750,10 @@ async function createAcpRuntime(input) {
|
||||
const client = {
|
||||
async requestPermission(params) {
|
||||
const turn = runtime.activeTurn
|
||||
if (runtime.agent.kind === 'mimo' && runtime.replyState.promptTerminalSequence != null) {
|
||||
runtime.replyState.permissionPendingAtPromptTerminal = true
|
||||
return { outcome: { outcome: 'cancelled' } }
|
||||
}
|
||||
const pending = permissionOutcome(params, turn?.options || {}, childEnv)
|
||||
turn?.pendingPermissionCallbacks.add(pending)
|
||||
pending.then(
|
||||
@@ -759,9 +767,21 @@ async function createAcpRuntime(input) {
|
||||
try { turn?.noteActivity?.() } catch { /* activity is best-effort */ }
|
||||
const sequence = runtime.replyState.pendingUpdateSequences.shift()
|
||||
if (!turn || !runtime.replyState.collecting) return
|
||||
if (params.sessionId !== runtime.protocolSessionRef) return
|
||||
if (runtime.replyState.promptTerminalSequence != null
|
||||
&& Number.isInteger(sequence)
|
||||
&& sequence > runtime.replyState.promptTerminalSequence) return
|
||||
&& sequence > runtime.replyState.promptTerminalSequence
|
||||
&& !(runtime.agent.kind === 'mimo'
|
||||
&& params.update?.sessionUpdate === 'agent_message_chunk'
|
||||
&& turn.lastMessageId
|
||||
&& params.update.messageId === turn.lastMessageId)) return
|
||||
if (params.update?.sessionUpdate === 'agent_message_chunk' && params.update.messageId) {
|
||||
turn.lastMessageId = params.update.messageId
|
||||
}
|
||||
if (turn.repetitionGuard?.(params.update)) {
|
||||
turn.stopRepeatedOutput()
|
||||
return
|
||||
}
|
||||
turn.structuredOutput.ingest?.(params)
|
||||
for (const event of turn.profile.mapEvent(params.update, turn.runtimeState)) {
|
||||
turn.runtimeEvents.emit(event)
|
||||
@@ -877,6 +897,7 @@ async function runAcpTurn(runtime, prompt, options, spec, profile) {
|
||||
noteActivity,
|
||||
outboundPayloadFailed: false,
|
||||
pendingPermissionCallbacks: new Set(),
|
||||
repetitionGuard: ['opencode', 'mimo', 'hermes'].includes(runtime.agent.kind) ? createOutputRepetitionGuard() : null,
|
||||
}
|
||||
runtime.activeTurn = turn
|
||||
runtime.stderr = []
|
||||
@@ -904,7 +925,7 @@ async function runAcpTurn(runtime, prompt, options, spec, profile) {
|
||||
let cancelPromise = Promise.resolve()
|
||||
let rejectAbort
|
||||
const abortPromise = new Promise((_, reject) => { rejectAbort = reject })
|
||||
const abort = () => {
|
||||
const abort = (error = agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED')) => {
|
||||
if (abortRequested) return
|
||||
abortRequested = true
|
||||
if (protocolSessionRef) {
|
||||
@@ -914,7 +935,12 @@ async function runAcpTurn(runtime, prompt, options, spec, profile) {
|
||||
})).catch(() => {})
|
||||
} catch { /* the ACP stream has already closed */ }
|
||||
}
|
||||
rejectAbort(agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED'))
|
||||
rejectAbort(error instanceof Error ? error : agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED'))
|
||||
}
|
||||
turn.stopRepeatedOutput = () => {
|
||||
runtime.replyState.collecting = false
|
||||
abort(agentExecutionError('LOCAL_AGENT_REPEATED_OUTPUT',
|
||||
'The Agent repeated the same response without tool progress; the native turn was cancelled.'))
|
||||
}
|
||||
const operation = promise => Promise.race([promise, abortPromise, runtime.failurePromise])
|
||||
const timeout = setTimeout(() => {
|
||||
@@ -969,6 +995,9 @@ async function runAcpTurn(runtime, prompt, options, spec, profile) {
|
||||
}))
|
||||
promptRequest.catch(() => {})
|
||||
promptResult = await operation(promptRequest)
|
||||
if (runtime.agent.kind === 'mimo' && turn.lastMessageId) {
|
||||
await operation(new Promise(resolve => setTimeout(resolve, MIMO_TERMINAL_DRAIN_MS)))
|
||||
}
|
||||
} finally {
|
||||
runtime.replyState.collecting = false
|
||||
}
|
||||
@@ -1037,7 +1066,8 @@ async function runAcpTurn(runtime, prompt, options, spec, profile) {
|
||||
ACP_CANCEL_SETTLE_MS,
|
||||
)
|
||||
}
|
||||
if (caughtError && promptSettled && !runtime.closed && !runtime.failureError) {
|
||||
if (caughtError && caughtError.code !== 'LOCAL_AGENT_REPEATED_OUTPUT'
|
||||
&& promptSettled && !runtime.closed && !runtime.failureError) {
|
||||
Object.defineProperty(caughtError, 'acpRuntimeReusable', {
|
||||
value: true, enumerable: false, configurable: true,
|
||||
})
|
||||
|
||||
@@ -30,6 +30,7 @@ const {
|
||||
shutdownAcpSessionRuntime,
|
||||
} = require('./cli-acp-runner.cjs')
|
||||
const { withOpenClawGateway } = require('./cli-openclaw-gateway.cjs')
|
||||
const { createOutputRepetitionGuard } = require('./cli-output-repetition.cjs')
|
||||
const { imageAttachmentLimit, invocation } = require('./cli-invocations.cjs')
|
||||
const {
|
||||
connectorLimitedRuntimeEvent,
|
||||
@@ -138,9 +139,11 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
sandbox: options.sandbox,
|
||||
attachments: options.attachments,
|
||||
hermesAcpAvailable,
|
||||
hermesStreamJsonAvailable: agent.streamJsonAvailable,
|
||||
invocationTransport: options.invocationTransport,
|
||||
sessionTransport: options.sessionTransport,
|
||||
maxTurns: options.maxTurns,
|
||||
model: agent.kind === 'workbuddy' ? options.env?.CODEBUDDY_MODEL : undefined,
|
||||
})
|
||||
const profile = resolveConnectorEventProfile(agent.kind, {
|
||||
transport: spec.eventTransport,
|
||||
@@ -224,6 +227,8 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
}))
|
||||
}
|
||||
const childEnv = childEnvironment(agent, workdir, options, platform)
|
||||
const repetitionGuard = ['opencode', 'mimo', 'hermes'].includes(agent.kind)
|
||||
? createOutputRepetitionGuard() : null
|
||||
const runtimeEvents = createRuntimeEventEmitter({ ...options, onActivity: noteActivity }, childEnv)
|
||||
const capabilityWarning = connectorLimitedRuntimeEvent(agent.kind, profile)
|
||||
if (capabilityWarning) runtimeEvents.emit(capabilityWarning)
|
||||
@@ -294,6 +299,12 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
const progressEvent = profile.mapProgress(event)
|
||||
if (progressEvent) emitProgress(progressEvent)
|
||||
for (const runtimeEvent of profile.mapEvent(event, runtimeState)) {
|
||||
if (stopRequested || settled) return
|
||||
if (repetitionGuard?.(runtimeEvent)) {
|
||||
inputError = agentExecutionError('LOCAL_AGENT_REPEATED_OUTPUT')
|
||||
abort()
|
||||
return
|
||||
}
|
||||
runtimeEvents.emit(runtimeEvent)
|
||||
}
|
||||
})
|
||||
@@ -326,6 +337,7 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
if (terminalObserved || !event || typeof event !== 'object') return
|
||||
const terminal = (
|
||||
(['workbuddy', 'claude', 'qwen'].includes(agent.kind) && event.type === 'result')
|
||||
|| (agent.kind === 'hermes' && spec.eventTransport === 'stream-json' && event.type === 'result')
|
||||
|| (agent.kind === 'pi' && event.type === 'agent_end')
|
||||
|| (agent.kind === 'codex' && event.type === 'turn.completed')
|
||||
|| (['mimo', 'opencode'].includes(agent.kind) && event.type === 'step_finish'
|
||||
@@ -458,13 +470,15 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
[rawStderr, structuredDetail].filter(Boolean).join('\n'),
|
||||
childEnv,
|
||||
)
|
||||
rejectTerminal(failedAgentProcessError(detail, { sessionRef: failureSessionRef }))
|
||||
rejectTerminal(failedAgentProcessError(detail, {
|
||||
sessionRef: failureSessionRef, kind: agent.kind, model: options.env?.CODEBUDDY_MODEL,
|
||||
}))
|
||||
return
|
||||
}
|
||||
if (result.error) {
|
||||
rejectTerminal(failedAgentProcessError(
|
||||
redactChildSecrets(result.error, childEnv),
|
||||
{ sessionRef: failureSessionRef },
|
||||
{ sessionRef: failureSessionRef, kind: agent.kind, model: options.env?.CODEBUDDY_MODEL },
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ const AGENT_PROFILES = {
|
||||
hermes: { label: 'Hermes', commands: ['hermes'] },
|
||||
openclaw: { label: 'OpenClaw', commands: ['openclaw'] },
|
||||
workbuddy: { label: 'WorkBuddy', commands: ['codebuddy'] },
|
||||
zcode: { label: 'Zcode', commands: ['zcode'] },
|
||||
pi: { label: 'Pi Agent', commands: ['pi', 'pi-agent', 'piagent'] },
|
||||
kimi: { label: 'Kimi', commands: ['kimi'] },
|
||||
mimo: { label: 'MiMo', commands: ['mimo'] },
|
||||
@@ -425,6 +426,8 @@ async function probeAgentCapabilities(kind, executable, options = {}) {
|
||||
compatibilityState: 'compatible',
|
||||
incompatibilityReason: '',
|
||||
incompatibilityProbe: '',
|
||||
...(kind === 'hermes' && results.some(result => result.output?.includes('--format')
|
||||
&& result.output.includes('stream-json')) ? { streamJsonAvailable: true } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,7 +498,10 @@ async function inspectAgentCandidate(kind, executable, options, childEnv) {
|
||||
executable,
|
||||
version,
|
||||
...compatibility,
|
||||
...(kind === 'hermes' ? { acpAvailable } : {}),
|
||||
...(kind === 'hermes' ? {
|
||||
acpAvailable,
|
||||
...(capabilityCompatibility.streamJsonAvailable ? { streamJsonAvailable: true } : {}),
|
||||
} : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -450,11 +450,99 @@ const HERMES_FINAL_TEXT_PROFILE = profile({
|
||||
createFinalOutputAccumulator: createHermesLegacyFinalOutputAccumulator,
|
||||
})
|
||||
|
||||
const HERMES_STREAM_JSON_PROFILE = profile({
|
||||
profileId: 'hermes-stream-json-v1',
|
||||
protocol: 'hermes-stream-json',
|
||||
framing: 'jsonl',
|
||||
source: 'stdout',
|
||||
capabilities: {
|
||||
answerMode: 'delta', tools: START_RESULT_TOOL_LIFECYCLE,
|
||||
plan: false, reasoning: false, session: true, terminal: true,
|
||||
},
|
||||
createDecoder: createJsonLineParser,
|
||||
createState: createStatelessRuntimeState,
|
||||
mapEvent(event) {
|
||||
if (event.type === 'text' && typeof event.text === 'string') {
|
||||
return [{ type: 'answer_delta', status: 'running', delta: event.text }]
|
||||
}
|
||||
if (['tool_use', 'tool_result'].includes(event.type)) {
|
||||
return [{
|
||||
id: event.tool_call_id || event.name,
|
||||
type: event.type === 'tool_use' ? 'tool_start' : 'tool_result_summary',
|
||||
title: event.name || 'tool',
|
||||
status: event.type === 'tool_use' ? 'running' : event.is_error ? 'failed' : 'completed',
|
||||
}]
|
||||
}
|
||||
return []
|
||||
},
|
||||
async finalizeResult({ result, options }) {
|
||||
// Both native chat formats use the same resumable conversation IDs.
|
||||
if (result.sessionRef) await options.onSessionRef?.(result.sessionRef, { transport: 'legacy' })
|
||||
return result
|
||||
},
|
||||
createFinalOutputAccumulator(sessionRef = '') {
|
||||
const state = { text: '', sessionRef, outcome: 'partial' }
|
||||
const ingest = event => {
|
||||
if (typeof event.session_id === 'string' && event.session_id) state.sessionRef = event.session_id
|
||||
if (event.type === 'text' && typeof event.text === 'string') appendFinalText(state, event.text)
|
||||
if (event.type === 'result') {
|
||||
if (typeof event.text === 'string') state.text = event.text.slice(0, MAX_FINAL_TEXT_CHARS)
|
||||
state.outcome = event.exit_code === 0 && !event.error ? 'completed' : 'failed'
|
||||
if (event.error) state.error = String(event.error)
|
||||
}
|
||||
}
|
||||
const parser = createJsonLineParser(ingest)
|
||||
return {
|
||||
ingest,
|
||||
write: chunk => parser.write(chunk),
|
||||
end() {
|
||||
parser.end()
|
||||
return { ...state }
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const ZCODE_JSON_PROFILE = profile({
|
||||
profileId: 'zcode-prompt-json-v1',
|
||||
protocol: 'zcode-prompt-json',
|
||||
framing: 'document',
|
||||
source: 'stdout',
|
||||
capabilities: {
|
||||
answerMode: 'final', tools: NO_TOOL_LIFECYCLE,
|
||||
plan: false, reasoning: false, session: true, terminal: true,
|
||||
},
|
||||
createState: createStatelessRuntimeState,
|
||||
createDecoder: null,
|
||||
mapEvent: finalOnlyRuntimeEvents,
|
||||
createFinalOutputAccumulator() {
|
||||
const decoder = new StringDecoder('utf8')
|
||||
const raw = { text: '' }
|
||||
return {
|
||||
capture(chunk) { appendFinalText(raw, decoder.write(chunk)) },
|
||||
end() {
|
||||
appendFinalText(raw, decoder.end())
|
||||
let value
|
||||
try { value = JSON.parse(raw.text) } catch { return { text: '', outcome: 'failed' } }
|
||||
if (typeof value.response !== 'string' || typeof value.sessionId !== 'string') {
|
||||
return { text: '', outcome: 'failed' }
|
||||
}
|
||||
return {
|
||||
text: value.response,
|
||||
sessionRef: value.sessionId,
|
||||
outcome: ['idle', 'completed'].includes(value.projection?.status) ? 'completed' : 'partial',
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const CONNECTOR_EVENT_PROFILES = Object.freeze({
|
||||
codex: CODEX_APP_SERVER_PROFILE,
|
||||
hermes: ACP_JSONRPC_PROFILE,
|
||||
openclaw: ACP_JSONRPC_PROFILE,
|
||||
workbuddy: WORKBUDDY_TERMINAL_RESULT_PROFILE,
|
||||
zcode: ZCODE_JSON_PROFILE,
|
||||
pi: PI_JSON_EVENTS_PROFILE,
|
||||
kimi: ACP_JSONRPC_PROFILE,
|
||||
mimo: MIMO_JSON_EVENTS_PROFILE,
|
||||
@@ -466,7 +554,7 @@ const CONNECTOR_EVENT_PROFILES = Object.freeze({
|
||||
})
|
||||
|
||||
const TRANSPORT_EVENT_PROFILES = Object.freeze({
|
||||
hermes: Object.freeze({ legacy: HERMES_FINAL_TEXT_PROFILE }),
|
||||
hermes: Object.freeze({ legacy: HERMES_FINAL_TEXT_PROFILE, 'stream-json': HERMES_STREAM_JSON_PROFILE }),
|
||||
openclaw: Object.freeze({ legacy: OPENCLAW_TERMINAL_DOCUMENT_PROFILE }),
|
||||
kimi: Object.freeze({ 'stream-json': KIMI_STREAM_JSON_PROFILE }),
|
||||
mimo: Object.freeze({ acp: ACP_JSONRPC_PROFILE }),
|
||||
|
||||
@@ -73,6 +73,18 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
|
||||
}
|
||||
}
|
||||
if (kind === 'hermes') {
|
||||
if (options.hermesStreamJsonAvailable === true
|
||||
&& (!sessionRef || options.sessionTransport !== 'acp')) {
|
||||
return {
|
||||
command: executable,
|
||||
args: ['chat', '--format', 'stream-json',
|
||||
...(options.sandbox === 'workspace-write' ? ['--yolo'] : []),
|
||||
...(sessionRef ? ['--resume', sessionRef] : []),
|
||||
...(attachments[0] ? ['--image', attachments[0]] : []), '--query'],
|
||||
promptArg: true,
|
||||
eventTransport: 'stream-json',
|
||||
}
|
||||
}
|
||||
// Hermes uses the same native conversation identifier across its ACP and
|
||||
// legacy transports. Keep it when an attachment requires the legacy path.
|
||||
const legacySessionRef = sessionRef
|
||||
@@ -150,12 +162,22 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
|
||||
// run any command anywhere on the machine without confirmation.
|
||||
'--permission-mode', options.sandbox === 'workspace-write' ? 'acceptEdits' : 'plan',
|
||||
...(maxTurns ? ['--max-turns', String(maxTurns)] : []),
|
||||
...(options.model ? ['--model', options.model] : []),
|
||||
...(sessionRef ? ['--resume', sessionRef] : []),
|
||||
],
|
||||
promptArg: true,
|
||||
eventTransport: 'stream-json',
|
||||
}
|
||||
}
|
||||
if (kind === 'zcode') {
|
||||
return {
|
||||
command: executable,
|
||||
args: ['--json', '--no-color', '--verbose', '--cwd', workdir,
|
||||
'--mode', options.sandbox === 'workspace-write' ? 'edit' : 'plan',
|
||||
...(sessionRef ? ['--resume', sessionRef] : []), '--prompt'],
|
||||
promptArg: true,
|
||||
}
|
||||
}
|
||||
if (kind === 'pi') {
|
||||
return {
|
||||
command: executable,
|
||||
@@ -175,7 +197,9 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
|
||||
return {
|
||||
command: executable,
|
||||
args: ['acp'],
|
||||
acpMode: 'plan',
|
||||
// Native plan mode requires a plan/approval cycle even for a query.
|
||||
// Manual mode keeps writes behind the ACP permission callback.
|
||||
acpMode: 'default',
|
||||
eventTransport: 'acp',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Detect a runaway native turn, rather than deduplicating its visible answer.
|
||||
// Only consecutive long repetitions count; real tool activity resets the window.
|
||||
function createOutputRepetitionGuard() {
|
||||
let text = ''
|
||||
let unchecked = 0
|
||||
return update => {
|
||||
const type = update?.sessionUpdate || update?.type
|
||||
if (['tool_call', 'tool_call_update', 'tool_start', 'tool_result_summary'].includes(type)) {
|
||||
text = ''
|
||||
unchecked = 0
|
||||
return false
|
||||
}
|
||||
const value = type === 'answer_delta' ? update.delta
|
||||
: type === 'agent_message_chunk' && update.content?.type === 'text' ? update.content.text : ''
|
||||
const chunk = String(value || '').replace(/\s/g, '')
|
||||
if (!chunk) return false
|
||||
text = (text + chunk).slice(-8192)
|
||||
unchecked += chunk.length
|
||||
if (unchecked < 32 || text.length < 64) return false
|
||||
unchecked = 0
|
||||
if (new Set(text).size < 6) return false
|
||||
const anchor = text.slice(-8)
|
||||
let previous = text.lastIndexOf(anchor, text.length - 16)
|
||||
while (previous >= 0) {
|
||||
const period = text.length - 8 - previous
|
||||
if (period > 2048) break
|
||||
const copies = period < 80 ? 8 : 4
|
||||
if (period * copies <= text.length) {
|
||||
const block = text.slice(-period)
|
||||
if (new Set(block).size >= 6 && text.endsWith(block.repeat(copies))) return true
|
||||
}
|
||||
previous = previous > 0 ? text.lastIndexOf(anchor, previous - 1) : -1
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { createOutputRepetitionGuard }
|
||||
@@ -26,7 +26,7 @@ const OPENCLAW_RUNTIME_PATH_KEYS = Object.freeze([
|
||||
'OPENCLAW_CONFIG_PATH',
|
||||
'OPENCLAW_WORKSPACE_DIR',
|
||||
])
|
||||
const CONTROLLED_RUNTIME_ENV = /^(?:AWS_|AZURE_|GOOGLE_|GEMINI_|ANTHROPIC_|OPENAI_|OPENROUTER_|CODEBUDDY_|HERMES_|PI_|KIMI_|MIMO_|DASHSCOPE_|CLAUDE_|OCR_|CLOUDSDK_|VERTEX_|CLOUD_ML_|MOONSHOT_|CONNECTOR_TEST_|MELDWORK_TEST_)/
|
||||
const CONTROLLED_RUNTIME_ENV = /^(?:AWS_|AZURE_|GOOGLE_|GEMINI_|ANTHROPIC_|OPENAI_|OPENROUTER_|CODEX_|CODEBUDDY_|HERMES_|PI_|KIMI_|MIMO_|MIMOCODE_|OPENCODE_|QWEN_|DASHSCOPE_|CLAUDE_|OCR_|CLOUDSDK_|VERTEX_|CLOUD_ML_|MOONSHOT_|CONNECTOR_TEST_|MELDWORK_TEST_)/
|
||||
const BLOCKED_RUNTIME_ENV = /^(?:NODE_OPTIONS|NODE_PATH|LD_PRELOAD|LD_LIBRARY_PATH|DYLD_|ELECTRON_RUN_AS_NODE)$/
|
||||
|
||||
function agentExecutionError(code, diagnostic = '') {
|
||||
@@ -52,6 +52,12 @@ function invalidSessionFailure(detail) {
|
||||
|
||||
function failedAgentProcessError(detail, options = {}) {
|
||||
if (!detail) return agentExecutionError('LOCAL_AGENT_EXITED')
|
||||
if (options.kind === 'zcode' && /\bModel provider is missing an API key:/i.test(detail)) {
|
||||
return agentExecutionError('LOCAL_AGENT_AUTH_REQUIRED', 'Missing API key for the configured Zcode provider.')
|
||||
}
|
||||
if (options.kind === 'workbuddy' && options.model && authFailureCode(detail)) {
|
||||
return agentExecutionError('LOCAL_WORKBUDDY_PROVIDER_AUTH', detail)
|
||||
}
|
||||
return agentExecutionError(
|
||||
options.sessionRef && invalidSessionFailure(detail)
|
||||
? 'LOCAL_AGENT_SESSION_INVALID'
|
||||
@@ -110,10 +116,21 @@ function childEnvironment(agent, workdir, options, platform) {
|
||||
? { HERMES_EXEC_ASK: '', HERMES_YOLO_MODE: '1' }
|
||||
: { HERMES_EXEC_ASK: '1', HERMES_YOLO_MODE: '' }
|
||||
: {}
|
||||
const openCodeSafetyEnv = agent.kind === 'opencode'
|
||||
&& options.sandbox !== 'workspace-write'
|
||||
? { OPENCODE_PERMISSION: OPENCODE_READ_ONLY_PERMISSION }
|
||||
: {}
|
||||
const openCodeSafetyEnv = {}
|
||||
// ACP has no terminal UI for question/plan transitions. Match the native
|
||||
// headless runner: return the answer to Meldwork instead of waiting forever.
|
||||
const headlessPermissions = { question: 'deny', plan_enter: 'deny', plan_exit: 'deny' }
|
||||
if (agent.kind === 'opencode') {
|
||||
openCodeSafetyEnv.OPENCODE_PERMISSION = JSON.stringify({
|
||||
...(options.sandbox !== 'workspace-write' ? JSON.parse(OPENCODE_READ_ONLY_PERMISSION) : {}),
|
||||
...headlessPermissions,
|
||||
})
|
||||
}
|
||||
if (agent.kind === 'mimo') {
|
||||
// MiMo applies read/write permissions with ACP session/set_mode. Keep
|
||||
// process-wide permissions stable when the discussion changes phase.
|
||||
openCodeSafetyEnv.MIMOCODE_PERMISSION = JSON.stringify(headlessPermissions)
|
||||
}
|
||||
const source = { ...process.env, ...options.env }
|
||||
const controlled = Object.fromEntries(Object.entries(options.env || {}).filter(([key, value]) => (
|
||||
typeof value === 'string'
|
||||
|
||||
@@ -99,7 +99,7 @@ function pathSuffixLength(value) {
|
||||
const matches = [
|
||||
value.match(/file:\/\/\/[^\s"'`<>|,;)}\]]+$/i),
|
||||
value.match(/(?<![A-Za-z0-9:])(?:[A-Za-z]:[\\/]|\\\\)[^\s"'`<>|,;)}\]]+$/),
|
||||
value.match(/(?<![A-Za-z0-9:/])\/+[^\s"'`<>|,;)}\]]+$/),
|
||||
value.match(/(?<![\p{L}\p{N}:/])\/+[^\s"'`<>|,;)}\]]+$/u),
|
||||
]
|
||||
return Math.max(...matches.map(match => match?.[0]?.length || 0))
|
||||
}
|
||||
@@ -107,6 +107,9 @@ function pathSuffixLength(value) {
|
||||
function boundarySensitiveSuffixLength(value, secrets) {
|
||||
let retained = 0
|
||||
const retain = (length) => { retained = Math.max(retained, length || 0) }
|
||||
// Keep a word boundary across chunks: a slash in the next chunk may be
|
||||
// prose (云/端), not the start of an absolute filesystem path.
|
||||
retain(value.match(/(?=[^\x00-\x7f])[\p{L}\p{N}]$/u)?.[0]?.length)
|
||||
for (const secret of secrets) {
|
||||
const limit = Math.min(value.length, secret.length - 1)
|
||||
for (let length = limit; length > retained; length -= 1) {
|
||||
@@ -123,7 +126,7 @@ function boundarySensitiveSuffixLength(value, secrets) {
|
||||
}
|
||||
const pathLength = pathSuffixLength(value)
|
||||
if (pathLength) retain(pathLength)
|
||||
retain(value.match(/(?<![A-Za-z0-9:/])\/+$/)?.[0]?.length)
|
||||
retain(value.match(/(?<![\p{L}\p{N}:/])\/+$/u)?.[0]?.length)
|
||||
|
||||
const matches = [
|
||||
value.match(/\bAKIA[0-9A-Z]{0,15}$/),
|
||||
@@ -206,7 +209,7 @@ function sanitizeRuntimeEventText(value, childEnv, limit, singleLine = false, tr
|
||||
.replace(/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/gi, '[redacted private key]')
|
||||
.replace(/file:\/\/\/[^\s"'`<>|,;)}\]]+/gi, '[path]')
|
||||
.replace(/(?<![A-Za-z0-9:])(?:[A-Za-z]:[\\/]|\\\\)[^\s"'`<>|,;)}\]]+/g, '[path]')
|
||||
.replace(/(?<![A-Za-z0-9:/])\/+[^\s"'`<>|,;)}\]]+/g, '[path]')
|
||||
.replace(/(?<![\p{L}\p{N}:/])\/+[^\s"'`<>|,;)}\]]+/gu, '[path]')
|
||||
.replace(/(["']?)(?:[A-Za-z0-9_.-]*(?:api[_-]?key|access[_-]?key(?:[_-]?id)?|secret[_-]?access[_-]?key|access[_-]?token|refresh[_-]?token|auth[_-]?token|database[_-]?url|connection[_-]?string|dsn|token|secret|password|credential|authorization|private[_-]?key)[A-Za-z0-9_.-]*)\1\s*[:=]\s*(?:"[^"\r\n]*"|'[^'\r\n]*'|\[[^\]\r\n]*\]|[^\s,;}\]]+)/gi, 'credential=[redacted]')
|
||||
.replace(/\bAKIA[0-9A-Z]{16}\b/g, '[redacted]')
|
||||
.replace(/\bbearer\s+[A-Za-z0-9._~+\/-]+=*/gi, 'Bearer [redacted]')
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const os = require('node:os')
|
||||
|
||||
function mimoConfiguredProvider(options = {}) {
|
||||
const env = options.env || process.env
|
||||
const root = env.XDG_CONFIG_HOME || path.join(options.home || os.homedir(), '.config')
|
||||
try {
|
||||
const file = path.join(root, 'mimocode', 'mimocode.json')
|
||||
if (fs.statSync(file).size > 2 * 1024 * 1024) return false
|
||||
const config = JSON.parse(fs.readFileSync(file, 'utf8'))
|
||||
if (typeof config.model !== 'string' || !config.model.includes('/')) return false
|
||||
const provider = config.provider?.[config.model.split('/')[0]]
|
||||
let key = provider?.options?.apiKey
|
||||
if (typeof key !== 'string' || !key.trim()) return false
|
||||
const reference = key.match(/^\{env:([A-Za-z_][A-Za-z0-9_]*)\}$/)
|
||||
if (reference) key = env[reference[1]]
|
||||
else if (/^\{.*\}$/.test(key)) return false
|
||||
return typeof key === 'string' && Boolean(key.trim())
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
module.exports = { mimoConfiguredProvider }
|
||||
@@ -0,0 +1,89 @@
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const { launchEnvironment } = require('./cli-discovery.cjs')
|
||||
|
||||
const MODEL_ID = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,119}$/
|
||||
|
||||
function workBuddyModels(options = {}) {
|
||||
const env = options.env || process.env
|
||||
const root = env.CODEBUDDY_CONFIG_DIR || path.join(options.home || os.homedir(), '.workbuddy')
|
||||
try {
|
||||
const filename = path.join(root, 'models.json')
|
||||
const stat = fs.statSync(filename)
|
||||
if (!stat.isFile() || stat.size > 2 * 1024 * 1024) return []
|
||||
const value = JSON.parse(fs.readFileSync(filename, 'utf8'))
|
||||
const models = Array.isArray(value) ? value : value.models
|
||||
return (Array.isArray(models) ? models : []).filter(model => (
|
||||
typeof model?.id === 'string' && MODEL_ID.test(model.id)
|
||||
&& typeof model.apiKey === 'string' && model.apiKey.trim()
|
||||
&& model.supportsToolCall === true && validUrl(model.url)
|
||||
)).slice(0, 64)
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
function validUrl(value) {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
function workBuddySettings(options = {}) {
|
||||
return {
|
||||
modelId: options.modelId || '',
|
||||
models: workBuddyModels(options).map(model => ({
|
||||
id: model.id, name: String(model.name || model.id).slice(0, 120),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function workBuddyEnvironment(options = {}) {
|
||||
if (!options.modelId) return {}
|
||||
const model = workBuddyModels(options).find(model => model.id === options.modelId)
|
||||
if (!model) throw new Error('LOCAL_WORKBUDDY_MODEL_UNAVAILABLE')
|
||||
// Use the CLI's supported provider settings, without copying desktop account
|
||||
// tokens or attempting to unlock its host-managed encrypted credential store.
|
||||
return {
|
||||
CODEBUDDY_CONFIG_DIR: (options.env || process.env).CODEBUDDY_CONFIG_DIR
|
||||
|| path.join(options.home || os.homedir(), '.workbuddy'),
|
||||
CODEBUDDY_MODEL: `custom-local:${model.id}`,
|
||||
CODEBUDDY_API_KEY: model.apiKey,
|
||||
CODEBUDDY_BASE_URL: model.url,
|
||||
}
|
||||
}
|
||||
|
||||
function workBuddyCredentialState(options = {}) {
|
||||
if (options.modelId) {
|
||||
try {
|
||||
workBuddyEnvironment(options)
|
||||
return { state: 'ready', source: 'native-credential' }
|
||||
} catch { return { state: 'unknown', source: 'workbuddy-model-unavailable' } }
|
||||
}
|
||||
const env = options.env || process.env
|
||||
if (env.CODEBUDDY_API_KEY?.trim() || env.CODEBUDDY_AUTH_TOKEN?.trim()) {
|
||||
return { state: 'ready', source: 'native-credential' }
|
||||
}
|
||||
// A models.json entry is not a selected provider. The desktop app's shared
|
||||
// encrypted login also cannot be decoded by its standalone lite CLI.
|
||||
if (launchEnvironment('workbuddy', options.executable, options).CODEBUDDY_FORCE_LITE_WB_BUNDLE !== '1') {
|
||||
return { state: 'unknown', source: 'unverified' }
|
||||
}
|
||||
const home = options.home || os.homedir()
|
||||
const platform = options.platform || process.platform
|
||||
const root = platform === 'darwin' ? path.join(home, 'Library', 'Application Support')
|
||||
: platform === 'win32' ? path.join(home, 'AppData', 'Local')
|
||||
: path.join(home, '.local', 'share')
|
||||
try {
|
||||
const filename = path.join(root, 'CodeBuddyExtension', 'Data', 'Public', 'auth', 'workbuddy-desktop.info')
|
||||
if (fs.statSync(filename).size <= 2 * 1024 * 1024) {
|
||||
const auth = JSON.parse(fs.readFileSync(filename, 'utf8')).auth
|
||||
if (auth?.accessToken && typeof auth.accessToken === 'object') {
|
||||
return { state: 'unknown', source: 'workbuddy-desktop-locked' }
|
||||
}
|
||||
}
|
||||
} catch { /* native CLI may still have an independent login */ }
|
||||
return { state: 'unknown', source: 'unverified' }
|
||||
}
|
||||
|
||||
module.exports = { workBuddyCredentialState, workBuddyEnvironment, workBuddySettings }
|
||||
@@ -7,6 +7,8 @@ const { promisify } = require('node:util')
|
||||
const { prepareCommand, searchPath } = require('./cli/cli-adapters.cjs')
|
||||
const { isOpenClawSecretReference } = require('./cli/openclaw-runtime.cjs')
|
||||
const { NETWORK_ENV_KEYS, networkEnvironment } = require('./cli/cli-network-environment.cjs')
|
||||
const { workBuddyCredentialState } = require('./cli/workbuddy-runtime.cjs')
|
||||
const { mimoConfiguredProvider } = require('./cli/mimo-native-config.cjs')
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
@@ -41,7 +43,8 @@ const CREDENTIAL_ENV_KEYS = Object.freeze({
|
||||
codex: ['OPENAI_API_KEY'],
|
||||
hermes: ['OPENAI_API_KEY', 'OPENROUTER_API_KEY', 'ANTHROPIC_API_KEY'],
|
||||
openclaw: ['OPENAI_API_KEY', 'OPENROUTER_API_KEY'],
|
||||
workbuddy: ['CODEBUDDY_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY'],
|
||||
workbuddy: ['CODEBUDDY_API_KEY', 'CODEBUDDY_AUTH_TOKEN'],
|
||||
zcode: ['ANTHROPIC_API_KEY'],
|
||||
pi: ['PI_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'OPENROUTER_API_KEY', 'GEMINI_API_KEY'],
|
||||
kimi: ['MOONSHOT_API_KEY', 'KIMI_API_KEY', 'KIMI_MODEL_API_KEY', 'OPENAI_API_KEY'],
|
||||
mimo: ['MIMO_API_KEY'],
|
||||
@@ -60,12 +63,14 @@ const RUNTIME_ENV_KEYS = Object.freeze({
|
||||
hermes: [
|
||||
'OPENAI_API_KEY', 'OPENROUTER_API_KEY', 'ANTHROPIC_API_KEY',
|
||||
'OPENAI_BASE_URL', 'OPENAI_MODEL', 'HERMES_INFERENCE_PROVIDER', 'HERMES_INFERENCE_MODEL',
|
||||
'HERMES_HOME',
|
||||
],
|
||||
openclaw: ['OPENAI_API_KEY', 'OPENROUTER_API_KEY'],
|
||||
workbuddy: [
|
||||
'CODEBUDDY_API_KEY', 'CODEBUDDY_BASE_URL', 'CODEBUDDY_MODEL',
|
||||
'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL', 'ANTHROPIC_API_KEY',
|
||||
'CODEBUDDY_API_KEY', 'CODEBUDDY_AUTH_TOKEN', 'CODEBUDDY_BASE_URL', 'CODEBUDDY_MODEL',
|
||||
'CODEBUDDY_CONFIG_DIR',
|
||||
],
|
||||
zcode: ['ANTHROPIC_API_KEY'],
|
||||
pi: [
|
||||
'PI_API_KEY', 'PI_BASE_URL', 'PI_MODEL',
|
||||
'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL', 'ANTHROPIC_API_KEY',
|
||||
@@ -74,9 +79,12 @@ const RUNTIME_ENV_KEYS = Object.freeze({
|
||||
kimi: [
|
||||
'MOONSHOT_API_KEY', 'KIMI_API_KEY', 'KIMI_MODEL_API_KEY',
|
||||
'KIMI_MODEL_BASE_URL', 'KIMI_MODEL_NAME',
|
||||
'KIMI_MODEL_PROVIDER_TYPE', 'KIMI_MODEL_CAPABILITIES',
|
||||
'KIMI_MODEL_MAX_CONTEXT_SIZE', 'KIMI_MODEL_MAX_OUTPUT_SIZE',
|
||||
'KIMI_CODE_HOME',
|
||||
'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL',
|
||||
],
|
||||
mimo: ['MIMO_API_KEY', 'MIMO_BASE_URL', 'MIMO_MODEL'],
|
||||
mimo: ['MIMO_API_KEY', 'MIMO_BASE_URL', 'MIMO_MODEL', 'MIMOCODE_CONFIG_DIR', 'MIMOCODE_CONFIG_CONTENT'],
|
||||
claude: [
|
||||
'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN',
|
||||
'ANTHROPIC_BASE_URL', 'ANTHROPIC_MODEL', 'CLAUDE_CONFIG_DIR',
|
||||
@@ -94,11 +102,12 @@ const RUNTIME_ENV_KEYS = Object.freeze({
|
||||
'AZURE_CLIENT_CERTIFICATE_PATH', 'AZURE_CLIENT_CERTIFICATE_PASSWORD', 'AZURE_FEDERATED_TOKEN_FILE',
|
||||
'AZURE_CONFIG_DIR',
|
||||
],
|
||||
qwen: ['DASHSCOPE_API_KEY', 'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL'],
|
||||
gemini: ['GEMINI_API_KEY', 'GOOGLE_API_KEY', 'GEMINI_MODEL', ...GOOGLE_CLOUD_ENV_KEYS],
|
||||
qwen: ['DASHSCOPE_API_KEY', 'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL', 'QWEN_HOME'],
|
||||
gemini: ['GEMINI_API_KEY', 'GOOGLE_API_KEY', 'GEMINI_MODEL', 'GOOGLE_GEMINI_BASE_URL', ...GOOGLE_CLOUD_ENV_KEYS],
|
||||
opencode: [
|
||||
'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'OPENROUTER_API_KEY',
|
||||
'OPENAI_BASE_URL', 'OPENAI_MODEL',
|
||||
'OPENCODE_CONFIG', 'OPENCODE_CONFIG_DIR', 'OPENCODE_CONFIG_CONTENT',
|
||||
],
|
||||
opencodereview: [
|
||||
'OCR_LLM_TOKEN', 'OCR_LLM_URL', 'OCR_LLM_MODEL', 'OCR_USE_ANTHROPIC',
|
||||
@@ -324,6 +333,7 @@ function resolveNativeShellEnvironment(options = {}) {
|
||||
}
|
||||
|
||||
function nativeCredentialState(kind, options = {}) {
|
||||
if (kind === 'workbuddy') return workBuddyCredentialState(options)
|
||||
const home = options.home || os.homedir()
|
||||
const env = options.env || process.env
|
||||
const codexHome = env.CODEX_HOME || path.join(home, '.codex')
|
||||
@@ -337,10 +347,10 @@ function nativeCredentialState(kind, options = {}) {
|
||||
}
|
||||
|
||||
const jsonFiles = {
|
||||
zcode: [path.join(home, '.zcode', 'cli', 'config.json')],
|
||||
codex: [path.join(codexHome, 'auth.json'), path.join(codexHome, 'config.json')],
|
||||
hermes: [path.join(home, '.hermes', 'auth.json'), path.join(home, '.hermes', 'config.json')],
|
||||
openclaw: [path.join(home, '.openclaw', 'openclaw.json')],
|
||||
workbuddy: [path.join(home, '.workbuddy', 'models.json')],
|
||||
pi: [
|
||||
path.join(piHome, 'auth.json'),
|
||||
path.join(piHome, 'models.json'),
|
||||
@@ -375,7 +385,6 @@ function nativeCredentialState(kind, options = {}) {
|
||||
path.join(home, '.hermes', 'config.yml'),
|
||||
],
|
||||
openclaw: [path.join(home, '.openclaw', '.env')],
|
||||
workbuddy: [path.join(home, '.workbuddy', '.env')],
|
||||
pi: [
|
||||
path.join(home, '.pi', '.env'),
|
||||
path.join(home, '.pi', 'config.toml'),
|
||||
@@ -744,8 +753,12 @@ async function queryMimoAuthState(options = {}) {
|
||||
}
|
||||
|
||||
async function resolveNativeCredentialState(kind, options = {}) {
|
||||
if (kind === 'workbuddy') return workBuddyCredentialState(options)
|
||||
const current = nativeCredentialState(kind, options)
|
||||
if (kind === 'mimo') {
|
||||
if (current.state === 'ready' || mimoConfiguredProvider(options)) {
|
||||
return { state: 'ready', source: 'native-credential' }
|
||||
}
|
||||
return await queryMimoAuthState(options) || current
|
||||
}
|
||||
if (kind === 'openclaw') {
|
||||
|
||||
@@ -12,6 +12,15 @@ const MAX_ATTACHMENT_DISCARD_REQUEST = 32
|
||||
|
||||
function attachmentIdsFromSnapshot(snapshot, groupId = '') {
|
||||
const ids = []
|
||||
for (const task of snapshot?.deliveryTasks || []) {
|
||||
if (groupId && task.groupId !== groupId) continue
|
||||
for (const version of task.versions) {
|
||||
for (const attachment of version.attachments) ids.push(attachment.id)
|
||||
}
|
||||
for (const attempt of task.attempts) {
|
||||
for (const source of attempt.sources) if (source.attachmentId) ids.push(source.attachmentId)
|
||||
}
|
||||
}
|
||||
for (const message of Array.isArray(snapshot?.messages) ? snapshot.messages : []) {
|
||||
if (groupId && message?.groupId !== groupId) continue
|
||||
for (const attachment of Array.isArray(message?.attachments) ? message.attachments : []) {
|
||||
|
||||
+11
-1
@@ -51,6 +51,7 @@ const {
|
||||
} = require('./agents/local-agent-readiness.cjs')
|
||||
const { LocalWorkspace } = require('./workspace/local-workspace.cjs')
|
||||
const { registerDesktopIpc } = require('./shell/main-ipc.cjs')
|
||||
const { workBuddyEnvironment, workBuddySettings } = require('./agents/cli/workbuddy-runtime.cjs')
|
||||
const {
|
||||
isAllowedExternalUrl,
|
||||
isAllowedLocalNavigation: isAllowedLocalNavigationForPaths,
|
||||
@@ -74,7 +75,7 @@ const { LocalKnowledgeConnectors } = require('./knowledge/local-knowledge-connec
|
||||
const { nativeOpenClawOptions } = require('./agents/cli/openclaw-runtime.cjs')
|
||||
const { migrateLegacyProductData } = require('./product-data-migration.cjs')
|
||||
const LOCAL_AGENT_KINDS = new Set([
|
||||
'codex', 'hermes', 'openclaw', 'workbuddy', 'pi', 'kimi', 'mimo', 'claude', 'gemini', 'opencode', 'qwen',
|
||||
'codex', 'hermes', 'openclaw', 'workbuddy', 'zcode', 'pi', 'kimi', 'mimo', 'claude', 'gemini', 'opencode', 'qwen',
|
||||
'opencodereview',
|
||||
])
|
||||
const QUIT_CLEANUP_TIMEOUT_MS = 2500
|
||||
@@ -366,6 +367,10 @@ async function runCoreAgent(agent, prompt, workdir, options = {}) {
|
||||
? {}
|
||||
: {
|
||||
...nativeCredentialEnvironment(agent.kind, shellEnvironment.env),
|
||||
...(agent.kind === 'workbuddy' ? workBuddyEnvironment({
|
||||
env: shellEnvironment.env, home: shellEnvironment.home,
|
||||
modelId: workspace?.state.agentPreferences.workbuddy?.modelId,
|
||||
}) : {}),
|
||||
...(shellEnvironment.env.PATH ? { PATH: shellEnvironment.env.PATH } : {}),
|
||||
}
|
||||
const callerEnv = connectorCredentialIsolation || agent.kind !== 'openclaw'
|
||||
@@ -546,6 +551,7 @@ function createWorkspace() {
|
||||
env: shellEnvironment.env,
|
||||
home: shellEnvironment.home,
|
||||
credentialSource: shellEnvironment.source,
|
||||
...(kind === 'workbuddy' ? { modelId: workspace?.state.agentPreferences.workbuddy?.modelId } : {}),
|
||||
})
|
||||
},
|
||||
agentLabel: kind => customAgentStore.label(kind) || agentConnectors.label(kind),
|
||||
@@ -743,6 +749,10 @@ function registerIpc() {
|
||||
getCloudAgentBridges: () => cloudAgentBridges,
|
||||
getMainWindow: () => mainWindow,
|
||||
getWorkspace: () => workspace,
|
||||
getWorkBuddySettings: async () => {
|
||||
const shell = await nativeShellEnvironment()
|
||||
return workBuddySettings({ ...shell, modelId: workspace?.state.agentPreferences.workbuddy?.modelId })
|
||||
},
|
||||
installer,
|
||||
isShutdownStarted: () => shutdownStarted,
|
||||
knowledgeBaseStore,
|
||||
|
||||
@@ -126,6 +126,21 @@ if (isLocalDocument) Object.assign(desktopApi, {
|
||||
localOutcome: Object.freeze({
|
||||
recordAdoption: input => ipcRenderer.invoke('local-outcome:record-adoption', input),
|
||||
}),
|
||||
localDelivery: Object.freeze({
|
||||
save: input => ipcRenderer.invoke('local-delivery:save', input),
|
||||
review: input => ipcRenderer.invoke('local-delivery:review', input),
|
||||
revise: input => ipcRenderer.invoke('local-delivery:revise', input),
|
||||
reuse: input => ipcRenderer.invoke('local-delivery:reuse', input),
|
||||
export: input => ipcRenderer.invoke('local-delivery:export', input),
|
||||
}),
|
||||
workBuddy: Object.freeze({
|
||||
settings: () => ipcRenderer.invoke('local-workbuddy:settings'),
|
||||
setModel: modelId => ipcRenderer.invoke('local-workbuddy:set-model', modelId),
|
||||
}),
|
||||
workPreferences: Object.freeze({
|
||||
get: () => ipcRenderer.invoke('local-work-preferences:get'),
|
||||
save: input => ipcRenderer.invoke('local-work-preferences:save', input),
|
||||
}),
|
||||
agentInstaller: Object.freeze({
|
||||
catalog: () => ipcRenderer.invoke('local-agent-installer:catalog'),
|
||||
skills: kind => ipcRenderer.invoke('local-agent-installer:skills', kind),
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { z } = require('zod')
|
||||
|
||||
const id = z.string().min(1).max(120).regex(/^[A-Za-z0-9._:-]+$/)
|
||||
function request(schema, input) {
|
||||
const parsed = schema.safeParse(input)
|
||||
if (!parsed.success) throw new Error('LOCAL_DELIVERY_INVALID')
|
||||
return parsed.data
|
||||
}
|
||||
|
||||
function registerDeliveryIpc({ registerTrustedHandle, getWorkspace, getAttachmentStore, dialog, getMainWindow }) {
|
||||
const workspace = () => {
|
||||
const value = getWorkspace()
|
||||
if (!value) throw new Error('LOCAL_WORKSPACE_UNAVAILABLE')
|
||||
return value
|
||||
}
|
||||
for (const method of ['save', 'review', 'revise']) {
|
||||
registerTrustedHandle(`local-delivery:${method}`, input => {
|
||||
const current = workspace()
|
||||
if (method === 'review' && input?.accept === true) {
|
||||
const task = current.delivery.task(input.taskId)
|
||||
const version = current.delivery.version(task, input.versionId)
|
||||
for (const attachment of version.attachments) {
|
||||
const store = getAttachmentStore()
|
||||
if (!store) throw new Error('LOCAL_ATTACHMENT_STORAGE_UNAVAILABLE')
|
||||
store.read(attachment.id)
|
||||
}
|
||||
}
|
||||
const result = current.delivery[method](input)
|
||||
current.emitChanged()
|
||||
return { taskId: method === 'save' ? result : input.taskId }
|
||||
})
|
||||
}
|
||||
registerTrustedHandle('local-delivery:reuse', input => {
|
||||
const { taskId } = request(z.strictObject({ taskId: id }), input)
|
||||
const current = workspace()
|
||||
const task = current.delivery.task(taskId)
|
||||
const original = current.getGroup(task.groupId)
|
||||
const root = path.join(path.dirname(current.storagePath), 'delivery-workspaces')
|
||||
fs.mkdirSync(root, { recursive: true, mode: 0o700 })
|
||||
const workdir = fs.mkdtempSync(path.join(root, 'task-'))
|
||||
let group
|
||||
try {
|
||||
group = current.createGroup({
|
||||
name: task.brief.title, agentKinds: original.agentKinds,
|
||||
conversationType: original.conversationType,
|
||||
directAgentKind: original.directAgentKind,
|
||||
workdir, allowWrite: false,
|
||||
})
|
||||
const newTaskId = current.delivery.save({ groupId: group.id, brief: task.brief })
|
||||
current.emitChanged()
|
||||
return { taskId: newTaskId, groupId: group.id }
|
||||
} catch (error) {
|
||||
if (group) current.deleteGroup(group.id)
|
||||
fs.rmdirSync(workdir)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
registerTrustedHandle('local-delivery:export', async input => {
|
||||
const { taskId, versionId, attachmentId } = request(z.strictObject({
|
||||
taskId: id, versionId: id, attachmentId: id.optional(),
|
||||
}), input)
|
||||
const current = workspace()
|
||||
const task = current.delivery.task(taskId)
|
||||
const version = current.delivery.version(task, versionId)
|
||||
const attachment = attachmentId ? version.attachments.find(item => item.id === attachmentId) : null
|
||||
if (attachmentId && !attachment) throw new Error('LOCAL_DELIVERY_NOT_FOUND')
|
||||
let bytes
|
||||
if (attachment) {
|
||||
const store = getAttachmentStore()
|
||||
if (!store) throw new Error('LOCAL_ATTACHMENT_STORAGE_UNAVAILABLE')
|
||||
bytes = store.read(attachment.id)
|
||||
} else bytes = Buffer.from(current.delivery.markdown(taskId, versionId), 'utf8')
|
||||
const filename = (attachment?.name || `${task.brief.title}-V${task.versions.indexOf(version) + 1}.md`)
|
||||
.replace(/[\\/\x00-\x1f:*?"<>|]/g, '_')
|
||||
const result = await dialog.showSaveDialog(getMainWindow(), { defaultPath: filename })
|
||||
if (result.canceled || !result.filePath) return { saved: false }
|
||||
try {
|
||||
fs.writeFileSync(result.filePath, bytes, { mode: 0o600, flag: fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_NOFOLLOW })
|
||||
} catch { throw new Error('LOCAL_DELIVERY_EXPORT_FAILED') }
|
||||
return { saved: true }
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { registerDeliveryIpc }
|
||||
@@ -1,6 +1,8 @@
|
||||
const { ADOPTION_ACTION_STATUSES } = require('../collaboration/outcome-records.cjs')
|
||||
const { statusForHumanGateOption } = require('../gates/human-gate-coordinator.cjs')
|
||||
const { ATTACHMENT_FILE_EXTENSIONS } = require('../attachments/attachment-records.cjs')
|
||||
const { registerDeliveryIpc } = require('./delivery-ipc.cjs')
|
||||
const { loadedWorkPreferences, parseWorkPreferences } = require('../workspace/work-preferences.cjs')
|
||||
|
||||
const LOCAL_GROUP_IDENTIFIER = /^[^\u0000-\u001f\u007f]{1,100}$/u
|
||||
const LOCAL_RUN_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/
|
||||
@@ -163,6 +165,7 @@ function sanitizeCloudAgentSnapshot(snapshot) {
|
||||
}
|
||||
|
||||
function registerDesktopIpc(options) {
|
||||
registerDeliveryIpc(options)
|
||||
const {
|
||||
app,
|
||||
attachmentIdsFromSnapshot,
|
||||
@@ -178,6 +181,7 @@ function registerDesktopIpc(options) {
|
||||
getOutcomeStore,
|
||||
getSkillTrustStore,
|
||||
getWorkspace,
|
||||
getWorkBuddySettings,
|
||||
installer,
|
||||
isShutdownStarted,
|
||||
knowledgeBaseStore,
|
||||
@@ -417,6 +421,51 @@ function registerDesktopIpc(options) {
|
||||
})
|
||||
registerTrustedHandle('local-workspace:default-directory', () => app.getPath('documents'))
|
||||
registerTrustedHandle('local-agent-installer:catalog', async () => localAgentCatalog())
|
||||
registerTrustedHandle('local-workbuddy:settings', () => getWorkBuddySettings())
|
||||
registerTrustedHandle('local-work-preferences:get', () => loadedWorkPreferences(getWorkspace()?.state.workPreferences))
|
||||
registerTrustedHandle('local-work-preferences:save', (...args) => {
|
||||
if (args.length !== 1) throw new Error('LOCAL_WORK_PREFERENCES_INVALID')
|
||||
const value = parseWorkPreferences(args[0])
|
||||
const workspace = getWorkspace()
|
||||
if (!workspace) throw new Error('LOCAL_WORKSPACE_UNAVAILABLE')
|
||||
if (workspace.activeRuns.size || workspace.preparingRuns?.size) throw new Error('LOCAL_WORK_PREFERENCES_BUSY')
|
||||
const previous = workspace.state.workPreferences
|
||||
workspace.state.workPreferences = value
|
||||
try { workspace.save() } catch (error) {
|
||||
if (previous) workspace.state.workPreferences = previous
|
||||
else delete workspace.state.workPreferences
|
||||
throw error
|
||||
}
|
||||
workspace.emitChanged()
|
||||
return value
|
||||
})
|
||||
registerTrustedHandle('local-workbuddy:set-model', async (...args) => {
|
||||
if (args.length !== 1 || typeof args[0] !== 'string' || args[0].length > 120) {
|
||||
throw new Error('LOCAL_WORKBUDDY_MODEL_UNAVAILABLE')
|
||||
}
|
||||
const workspace = getWorkspace()
|
||||
if (!workspace) throw new Error('LOCAL_WORKSPACE_UNAVAILABLE')
|
||||
const modelId = args[0]
|
||||
const settings = await getWorkBuddySettings()
|
||||
if (modelId && !settings.models.some(model => model.id === modelId)) {
|
||||
throw new Error('LOCAL_WORKBUDDY_MODEL_UNAVAILABLE')
|
||||
}
|
||||
if (workspace.activeRuns.size || workspace.preparingRuns?.size) {
|
||||
throw new Error('LOCAL_WORKBUDDY_BUSY')
|
||||
}
|
||||
const before = workspace.state.agentPreferences.workbuddy
|
||||
const runtime = workspace.state.agentRuntime.workbuddy
|
||||
workspace.state.agentPreferences.workbuddy = { ...before, modelId }
|
||||
delete workspace.state.agentRuntime.workbuddy
|
||||
try { workspace.save() } catch (error) {
|
||||
if (before) workspace.state.agentPreferences.workbuddy = before
|
||||
else delete workspace.state.agentPreferences.workbuddy
|
||||
if (runtime) workspace.state.agentRuntime.workbuddy = runtime
|
||||
throw error
|
||||
}
|
||||
await refreshLocalAgentState()
|
||||
return { ...settings, modelId }
|
||||
})
|
||||
registerTrustedHandle('local-agent-installer:skills', async (kind) => {
|
||||
const selectedKind = String(kind || '')
|
||||
if (customAgentStore.has(selectedKind)) {
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
const { randomUUID } = require('node:crypto')
|
||||
const { z } = require('zod')
|
||||
const { normalizeAttachmentMetadata } = require('./local-workspace-message-records.cjs')
|
||||
|
||||
const id = z.string().min(1).max(120).regex(/^[A-Za-z0-9._:-]+$/)
|
||||
const text = (max) => z.string().trim().min(1).max(max)
|
||||
const briefSchema = z.strictObject({
|
||||
title: text(120), goal: text(4000), output: text(2000),
|
||||
outputKind: z.enum(['text', 'files']),
|
||||
criteria: z.array(text(300)).min(1).max(20),
|
||||
language: z.enum(['zh', 'en']),
|
||||
})
|
||||
const attachmentSchema = z.strictObject({
|
||||
id, name: text(160), mimeType: text(160), size: z.number().int().positive(),
|
||||
})
|
||||
const sourceSchema = z.strictObject({ name: text(200), kind: text(40), attachmentId: id.optional() })
|
||||
const revisionSchema = z.strictObject({ baseVersionId: id, note: text(4000) })
|
||||
const attemptSchema = z.strictObject({
|
||||
runId: id, messageId: id, status: text(40), createdAt: text(80),
|
||||
input: z.string().max(200000), sources: z.array(sourceSchema).max(41),
|
||||
revision: revisionSchema.nullable(),
|
||||
})
|
||||
const versionSchema = z.strictObject({
|
||||
id, runId: id, createdAt: text(80),
|
||||
responses: z.array(z.strictObject({ messageId: id, agentKind: text(40), content: z.string().max(1000000) })).max(32),
|
||||
attachments: z.array(attachmentSchema).max(128),
|
||||
checks: z.array(z.enum(['pending', 'met', 'unmet', 'unknown'])).max(20),
|
||||
notes: z.string().max(4000), acceptedAt: z.string().max(80),
|
||||
})
|
||||
const taskSchema = z.strictObject({
|
||||
id, groupId: text(100), brief: briefSchema, createdAt: text(80),
|
||||
attempts: z.array(attemptSchema).max(200), versions: z.array(versionSchema).max(200),
|
||||
revision: revisionSchema.nullable(),
|
||||
})
|
||||
|
||||
function parse(schema, input) {
|
||||
const result = schema.safeParse(input)
|
||||
if (!result.success) throw new Error('LOCAL_DELIVERY_INVALID')
|
||||
return result.data
|
||||
}
|
||||
|
||||
function parseDeliveryTasks(value = []) {
|
||||
return parse(z.array(taskSchema).max(1000), value)
|
||||
}
|
||||
|
||||
class DeliveryTasks {
|
||||
constructor(workspace) {
|
||||
this.workspace = workspace
|
||||
this.pending = new Map()
|
||||
}
|
||||
|
||||
get tasks() { return this.workspace.state.deliveryTasks || [] }
|
||||
|
||||
task(taskId) {
|
||||
parse(id, taskId)
|
||||
const task = this.tasks.find(item => item.id === taskId)
|
||||
if (!task || !this.workspace.state.groups.some(group => group.id === task.groupId)) {
|
||||
throw new Error('LOCAL_DELIVERY_NOT_FOUND')
|
||||
}
|
||||
return task
|
||||
}
|
||||
|
||||
version(task, versionId) {
|
||||
parse(id, versionId)
|
||||
const version = task.versions.find(item => item.id === versionId)
|
||||
if (!version) throw new Error('LOCAL_DELIVERY_NOT_FOUND')
|
||||
return version
|
||||
}
|
||||
|
||||
commit(change) {
|
||||
const previous = structuredClone(this.tasks)
|
||||
try {
|
||||
this.workspace.state.deliveryTasks ||= []
|
||||
const result = change()
|
||||
parseDeliveryTasks(this.tasks)
|
||||
this.workspace.save()
|
||||
return result
|
||||
} catch (error) {
|
||||
this.workspace.state.deliveryTasks = previous
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
idle(task) {
|
||||
if (this.workspace.isGroupBusy(task.groupId) || this.pending.has(task.groupId)) {
|
||||
throw new Error('LOCAL_GROUP_RUNNING')
|
||||
}
|
||||
}
|
||||
|
||||
save(input) {
|
||||
const request = parse(z.strictObject({ groupId: text(100), taskId: id.optional(), brief: briefSchema }), input)
|
||||
this.workspace.getGroup(request.groupId)
|
||||
if (this.workspace.isGroupBusy(request.groupId)) throw new Error('LOCAL_GROUP_RUNNING')
|
||||
const existing = request.taskId ? this.task(request.taskId) : null
|
||||
if (existing && (existing.groupId !== request.groupId || existing.attempts.length)) {
|
||||
throw new Error('LOCAL_DELIVERY_LOCKED')
|
||||
}
|
||||
return this.commit(() => {
|
||||
if (existing) { existing.brief = request.brief; return existing.id }
|
||||
const task = {
|
||||
id: `delivery-${randomUUID()}`, groupId: request.groupId, brief: request.brief,
|
||||
createdAt: this.workspace.now(), attempts: [], versions: [], revision: null,
|
||||
}
|
||||
this.tasks.push(task)
|
||||
return task.id
|
||||
})
|
||||
}
|
||||
|
||||
prepare(input) {
|
||||
const task = this.task(input.deliveryTaskId)
|
||||
if (task.groupId !== input.groupId || input.regenerateMessageId || input.threadRootId) {
|
||||
throw new Error('LOCAL_DELIVERY_INVALID')
|
||||
}
|
||||
this.idle(task)
|
||||
if (task.versions.length && !task.revision) throw new Error('LOCAL_DELIVERY_REVISION_REQUIRED')
|
||||
const brief = task.brief
|
||||
const base = task.revision ? this.version(task, task.revision.baseVersionId) : null
|
||||
if (task.attempts.length >= 200) throw new Error('LOCAL_DELIVERY_INPUT_LIMIT')
|
||||
if (input.attachments != null && !Array.isArray(input.attachments)) throw new Error('LOCAL_DELIVERY_INVALID')
|
||||
const attachments = [...new Map([...(base?.attachments || []), ...(input.attachments || [])]
|
||||
.map(item => [item.id, item])).values()]
|
||||
if (attachments.length > 32) throw new Error('LOCAL_ATTACHMENT_COUNT_LIMIT')
|
||||
const prompt = [
|
||||
brief.language === 'zh' ? '工作交付要求(请用中文回答)' : 'Work delivery requirements (respond in English)',
|
||||
JSON.stringify(brief),
|
||||
'Produce the requested deliverable. Identify source materials for key claims, conflicting or missing information, and unresolved issues. Never invent missing data or claim that a file exists without creating it. Human acceptance is separate from your self-review.',
|
||||
...(base ? ['Previous version:', ...base.responses.map(item => item.content), `Revision request: ${task.revision.note}`] : []),
|
||||
`Additional instructions: ${String(input.text || '')}`,
|
||||
].join('\n\n')
|
||||
if (prompt.length > 200000) throw new Error('LOCAL_DELIVERY_INPUT_LIMIT')
|
||||
this.pending.set(task.groupId, { taskId: task.id, input: String(input.text || '') })
|
||||
const { deliveryTaskId: _id, ...rest } = input
|
||||
return { ...rest, text: prompt, attachments }
|
||||
}
|
||||
|
||||
bind(groupId, controller, messageId) {
|
||||
const pending = this.pending.get(groupId)
|
||||
if (!pending) return
|
||||
const task = this.task(pending.taskId)
|
||||
const message = this.workspace.state.messages.find(item => item.id === messageId)
|
||||
if (!message || message.groupId !== groupId) throw new Error('LOCAL_DELIVERY_INVALID')
|
||||
this.commit(() => task.attempts.push({
|
||||
runId: controller.runId, messageId, status: 'running', createdAt: this.workspace.now(),
|
||||
input: pending.input, revision: task.revision ? { ...task.revision } : null,
|
||||
sources: [
|
||||
{ name: this.workspace.getGroup(groupId).name || 'Conversation', kind: 'conversation' },
|
||||
...(message.attachments || []).map(item => ({ name: item.name, kind: 'attachment', attachmentId: item.id })),
|
||||
...(message.knowledgeBaseHints || []).map(item => ({ name: item.name, kind: item.kind })),
|
||||
...(message.skillHints || []).map(item => ({ name: item.name, kind: 'skill' })),
|
||||
],
|
||||
}))
|
||||
}
|
||||
|
||||
complete(runId, status, committedMessageIds = []) {
|
||||
const task = this.tasks.find(item => item.attempts.some(attempt => attempt.runId === runId))
|
||||
if (!task) return
|
||||
const attempt = task.attempts.find(item => item.runId === runId)
|
||||
if (!['running', 'waiting', 'preparing'].includes(attempt.status)) return
|
||||
const messages = this.workspace.state.messages
|
||||
const rootIndex = messages.findIndex(item => item.id === attempt.messageId)
|
||||
const nextUserIndex = messages.findIndex((item, index) => index > rootIndex && item.groupId === task.groupId && item.role === 'user')
|
||||
const related = messages.filter((message, index) => {
|
||||
if (message.role !== 'agent' || message.groupId !== task.groupId) return false
|
||||
if (committedMessageIds.includes(message.id)) return true
|
||||
if (message.trace?.runId && message.trace.runId !== runId) return false
|
||||
return message.trace?.runId === runId
|
||||
|| message.threadRootId === attempt.messageId
|
||||
|| (!message.threadRootId && rootIndex >= 0 && index > rootIndex && (nextUserIndex < 0 || index < nextUserIndex))
|
||||
})
|
||||
const outputs = related.filter(message => {
|
||||
const phase = message.trace?.phase || message.trace?.context?.phase
|
||||
return committedMessageIds.includes(message.id)
|
||||
|| !['proposal', 'discussion', 'challenge', 'coordination', 'work', 'verification'].includes(phase)
|
||||
}).filter(message => message.content?.trim())
|
||||
const attachments = [...new Map(related.flatMap(item => item.attachments || [])
|
||||
.map(normalizeAttachmentMetadata).filter(Boolean).map(item => [item.id, item])).values()]
|
||||
this.commit(() => {
|
||||
attempt.status = status
|
||||
if (!outputs.length && !attachments.length) return
|
||||
task.versions.push({
|
||||
id: `version-${randomUUID()}`, runId, createdAt: this.workspace.now(),
|
||||
responses: outputs.map(item => ({ messageId: item.id, agentKind: item.agentKind, content: item.content || '' })),
|
||||
attachments, checks: task.brief.criteria.map(() => 'pending'), notes: '', acceptedAt: '',
|
||||
})
|
||||
task.revision = null
|
||||
})
|
||||
}
|
||||
|
||||
recover() {
|
||||
for (const task of this.tasks) {
|
||||
for (const attempt of task.attempts) {
|
||||
if (!['running', 'waiting', 'preparing'].includes(attempt.status)) continue
|
||||
const record = this.workspace.runLedger?.list?.().find(item => item.runId === attempt.runId)
|
||||
if (this.workspace.activeRuns.has(task.groupId)
|
||||
|| (record?.status === 'waiting' && this.workspace.canResumeHumanGateRecord(record))) continue
|
||||
const status = record && !['running', 'waiting', 'preparing', 'queued'].includes(record.status)
|
||||
? record.status : 'interrupted'
|
||||
this.complete(attempt.runId, status, [
|
||||
...(record?.orchestration?.commitState?.messageIds || []),
|
||||
record?.orchestration?.candidateCommit?.messageId,
|
||||
].filter(Boolean))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
review(input) {
|
||||
const request = parse(z.strictObject({
|
||||
taskId: id, versionId: id, checks: z.array(z.enum(['pending', 'met', 'unmet', 'unknown'])).max(20),
|
||||
notes: z.string().max(4000), accept: z.boolean(),
|
||||
}), input)
|
||||
const task = this.task(request.taskId)
|
||||
this.idle(task)
|
||||
const version = this.version(task, request.versionId)
|
||||
if (version.acceptedAt || request.checks.length !== task.brief.criteria.length) throw new Error('LOCAL_DELIVERY_LOCKED')
|
||||
if (request.accept && (request.checks.some(check => check !== 'met')
|
||||
|| (task.brief.outputKind === 'files' && !version.attachments.length))) {
|
||||
throw new Error('LOCAL_DELIVERY_NOT_READY')
|
||||
}
|
||||
this.commit(() => {
|
||||
version.checks = request.checks
|
||||
version.notes = request.notes.trim()
|
||||
if (request.accept) version.acceptedAt = this.workspace.now()
|
||||
})
|
||||
}
|
||||
|
||||
revise(input) {
|
||||
const request = parse(z.strictObject({ taskId: id, baseVersionId: id, note: text(4000) }), input)
|
||||
const task = this.task(request.taskId)
|
||||
this.idle(task)
|
||||
this.version(task, request.baseVersionId)
|
||||
this.commit(() => { task.revision = { baseVersionId: request.baseVersionId, note: request.note } })
|
||||
}
|
||||
|
||||
markdown(taskId, versionId) {
|
||||
const task = this.task(taskId)
|
||||
const version = this.version(task, versionId)
|
||||
const attempt = task.attempts.find(item => item.runId === version.runId)
|
||||
const zh = task.brief.language === 'zh'
|
||||
const checks = zh
|
||||
? { pending: '待检查', met: '符合', unmet: '不符合', unknown: '无法判断' }
|
||||
: { pending: 'Not checked', met: 'Met', unmet: 'Not met', unknown: 'Unknown' }
|
||||
return [
|
||||
`# ${task.brief.title}`, `V${task.versions.indexOf(version) + 1} · ${version.createdAt}`,
|
||||
`${zh ? '采用时间' : 'Accepted at'}: ${version.acceptedAt || (zh ? '未采用' : 'Not accepted')}`,
|
||||
...version.responses.map(item => `## ${item.agentKind}\n\n${item.content}`),
|
||||
`## ${zh ? '输入材料(不代表逐条引用已核实)' : 'Input materials (not verified claim-level citations)'}`,
|
||||
...attempt.sources.map(item => `- ${item.name} (${item.kind})`),
|
||||
`## ${zh ? '人工验收' : 'Human review'}`,
|
||||
...task.brief.criteria.map((criterion, index) => `- [${checks[version.checks[index]]}] ${criterion}`),
|
||||
`## ${zh ? '备注与未决事项' : 'Notes and unresolved issues'}`, version.notes || '—',
|
||||
`## ${zh ? '附件(需单独导出)' : 'Attachments (export separately)'}`,
|
||||
...version.attachments.map(item => `- ${item.name}`),
|
||||
].join('\n\n') + '\n'
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { DeliveryTasks, parseDeliveryTasks }
|
||||
@@ -96,6 +96,7 @@ class LocalWorkspaceAgentCatalog {
|
||||
const native = nativeStates[index]
|
||||
let runtime = state.agentRuntime[agent.kind]
|
||||
const nativeState = ['ready', 'missing'].includes(native?.state) ? native.state : 'unknown'
|
||||
const workBuddyUnavailable = ['workbuddy-desktop-locked', 'workbuddy-model-unavailable'].includes(native?.source)
|
||||
const authoritativeNativeState = native?.source === 'native-auth-status'
|
||||
// Give corrected native credentials a retry after the cooldown, never erase a newer failure.
|
||||
if (expiredAuthFailure(runtime, this.now())
|
||||
@@ -138,6 +139,7 @@ class LocalWorkspaceAgentCatalog {
|
||||
const versionIdentified = agentVersionIdentified(agent)
|
||||
const compatible = agentCompatible(agent)
|
||||
const nativeConfigurationUnknown = !runtimeMissing
|
||||
&& !workBuddyUnavailable
|
||||
&& nativeState === 'unknown'
|
||||
&& native?.source !== 'native-runtime-unavailable'
|
||||
&& native?.source !== 'native-probe-failed'
|
||||
@@ -155,7 +157,7 @@ class LocalWorkspaceAgentCatalog {
|
||||
|| (native?.source !== 'native-runtime-unavailable'
|
||||
&& (native?.source !== 'native-probe-failed' || recentlyVerified(runtime, this.now())))
|
||||
const invocable = installed && versionIdentified && compatible && configured
|
||||
&& authenticated && runtimePrerequisitesReady
|
||||
&& authenticated && runtimePrerequisitesReady && !workBuddyUnavailable
|
||||
const verifiedRecently = (authoritativeNativeState && nativeState === 'ready')
|
||||
|| recentlyVerified(runtime, this.now())
|
||||
const available = invocable
|
||||
@@ -173,6 +175,7 @@ class LocalWorkspaceAgentCatalog {
|
||||
else if (verifiedReady) availabilitySource = 'verified-run'
|
||||
else if (nativeConfigurationUnknown) availabilitySource = 'local-cli'
|
||||
if (retained) availabilitySource = retained.availabilitySource
|
||||
if (workBuddyUnavailable) availabilitySource = native.source
|
||||
return {
|
||||
...agent,
|
||||
installed,
|
||||
@@ -222,7 +225,7 @@ class LocalWorkspaceAgentCatalog {
|
||||
const agent = this.detectedAgents().find(item => item.kind === kind)
|
||||
if (!agent) throw new Error('LOCAL_AGENT_NOT_INSTALLED')
|
||||
const state = this.state()
|
||||
state.agentPreferences[kind] = { showInSidebar: Boolean(visible) }
|
||||
state.agentPreferences[kind] = { ...state.agentPreferences[kind], showInSidebar: Boolean(visible) }
|
||||
agent.showInSidebar = Boolean(visible)
|
||||
this.save()
|
||||
this.emitChanged()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const { createHash, randomUUID } = require('node:crypto')
|
||||
const { workPreferencesPrompt } = require('./work-preferences.cjs')
|
||||
const {
|
||||
assertResumedPermissionBinding,
|
||||
automaticPermissionResponse,
|
||||
@@ -625,8 +626,9 @@ class LocalWorkspaceAgentInvocation {
|
||||
const answerFilter = createAnswerMarkerFilter({
|
||||
mode: context.v4 === true ? 'receipt' : (mode === 'auto' ? 'auto' : 'plain'),
|
||||
})
|
||||
let flushingTerminalAnswer = false
|
||||
const emitHarnessEvent = (rawEvent) => {
|
||||
if (agentCallbacksClosed || agentController.signal.aborted
|
||||
if (agentCallbacksClosed || (agentController.signal.aborted && !flushingTerminalAnswer)
|
||||
|| this.activeRuns.get(group.id) !== activeRun
|
||||
|| !harness || !harnessRun || !rawEvent) return
|
||||
const event = harness.ingest(kind, round, rawEvent, harnessRun.agentRunId)
|
||||
@@ -706,7 +708,9 @@ class LocalWorkspaceAgentInvocation {
|
||||
}
|
||||
const finishHarness = (status, finalText = '', runtimeContext = {}) => {
|
||||
if (!harness || !harnessRun || harnessFinished) return null
|
||||
flushingTerminalAnswer = true
|
||||
flushRuntimeEvent(finalText)
|
||||
flushingTerminalAnswer = false
|
||||
closeAgentCallbacks()
|
||||
this.clearAgentSilence(activeRun, kind, round, harnessRun.agentRunId)
|
||||
const finished = harness.finishAgent(kind, round, status, finalText, {
|
||||
@@ -757,7 +761,8 @@ class LocalWorkspaceAgentInvocation {
|
||||
stagedInputs = isolated
|
||||
? null
|
||||
: stageAgentInputs(group.workdir, context.attachmentSnapshots || [], nativeImageLimit)
|
||||
const buildPrompt = (afterKind, contextPackage) => assembleInvocationPrompt({
|
||||
const preferencesText = isolated ? '' : workPreferencesPrompt(this.state().workPreferences)
|
||||
const buildPrompt = (afterKind, contextPackage) => [assembleInvocationPrompt({
|
||||
group,
|
||||
kind,
|
||||
mode,
|
||||
@@ -774,7 +779,7 @@ class LocalWorkspaceAgentInvocation {
|
||||
stagedInputs,
|
||||
promptFor: (...args) => this.promptFor(...args),
|
||||
agentLabel: label => this.agentLabel(label),
|
||||
})
|
||||
}), preferencesText].filter(Boolean).join('\n\n')
|
||||
let prompt = buildPrompt(transcriptAfterKind, packedContext)
|
||||
const recordOutboundPayload = (outbound = {}) => {
|
||||
watchdog.progress()
|
||||
|
||||
@@ -629,6 +629,11 @@ const v4PhaseMethods = {
|
||||
slot.status = 'stopped'
|
||||
slot.commitStatus = 'partial'
|
||||
slot.finishedAt = Date.now()
|
||||
const error = item.error || item.invocation?.error
|
||||
if (error?.runTrace) {
|
||||
this.recordAgentInterruption(group.id, item.kind, error, threadRootId,
|
||||
controller.stopReason === 'shutdown' ? 'interrupted' : 'stopped')
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (!result) {
|
||||
|
||||
@@ -321,6 +321,9 @@ const v4ResultMethods = {
|
||||
failures.push({ kind, error })
|
||||
if (!controller.signal.aborted) {
|
||||
this.recordAgentFailure(group.id, kind, error, threadRootId, new Set())
|
||||
} else if (error?.runTrace) {
|
||||
this.recordAgentInterruption(group.id, kind, error, threadRootId,
|
||||
controller.stopReason === 'shutdown' ? 'interrupted' : 'stopped')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,6 +358,11 @@ const v4ResultMethods = {
|
||||
slot.status = 'stopped'
|
||||
slot.commitStatus = 'partial'
|
||||
slot.finishedAt = Date.now()
|
||||
const error = invocation.error
|
||||
if (error?.runTrace) {
|
||||
this.recordAgentInterruption(group.id, kind, error, threadRootId,
|
||||
controller.stopReason === 'shutdown' ? 'interrupted' : 'stopped')
|
||||
}
|
||||
checkpoint()
|
||||
return
|
||||
}
|
||||
@@ -483,6 +491,11 @@ const v4ResultMethods = {
|
||||
slot.status = 'stopped'
|
||||
slot.commitStatus = 'partial'
|
||||
slot.finishedAt = Date.now()
|
||||
const error = item.error || item.invocation?.error
|
||||
if (controller.signal.aborted && error?.runTrace) {
|
||||
this.recordAgentInterruption(group.id, item.kind, error, threadRootId,
|
||||
controller.stopReason === 'shutdown' ? 'interrupted' : 'stopped')
|
||||
}
|
||||
}
|
||||
return { workReceipts: input.workReceipts, failure: null }
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ const {
|
||||
responseLanguagePrompt,
|
||||
} = require('./prompts/prompt-contracts.cjs')
|
||||
const { naturalBrief } = require('./prompts/natural-discussion-prompt.cjs')
|
||||
const { sharedContextExcerpt } = require('./shared-context-excerpt.cjs')
|
||||
|
||||
const CONTINUATION_STABLE_CONTEXT_TEXT_LIMIT = 1400
|
||||
const CONTINUATION_RECENT_CONTEXT_TEXT_LIMIT = 4600
|
||||
@@ -37,8 +38,8 @@ const CONTINUATION_RECENT_CONTEXT_TEXT_LIMIT = 4600
|
||||
// documents unusable. The extra room covers the attached-file note.
|
||||
const CURRENT_TASK_TEXT_LIMIT = MAX_MESSAGE_TEXT_CHARS + 2000
|
||||
const V4_SNAPSHOT_HISTORY_LIMIT = 16
|
||||
const NATURAL_TRANSCRIPT_TEXT_LIMIT = 48000
|
||||
const NATURAL_TRANSCRIPT_ENTRY_LIMIT = 20000
|
||||
const NATURAL_TRANSCRIPT_TEXT_LIMIT = 16000
|
||||
const NATURAL_TRANSCRIPT_ENTRY_LIMIT = 2400
|
||||
const NATURAL_TRANSCRIPT_OMISSION_NOTICE = 'This transcript is partial: some turns or parts of turns were omitted to fit the context budget. Omission is not evidence of agreement or completion. Ask peers to restate any missing evidence needed to judge the original task.'
|
||||
const V4_SNAPSHOT_FIELDS = [
|
||||
'group', 'history', 'messageId', 'phase', 'snapshotHash',
|
||||
@@ -72,17 +73,16 @@ function packNaturalDiscussionTranscript(entries, options = {}) {
|
||||
const latestByKind = new Map(entries.map((entry, index) => [entry.agentKind, index]))
|
||||
let excerpted = entries.some(entry => entry.partial === true)
|
||||
const completeBodies = new Map()
|
||||
const excerptedMessageIds = []
|
||||
const fairEntryLimit = Math.min(NATURAL_TRANSCRIPT_ENTRY_LIMIT, Math.max(128,
|
||||
Math.floor((NATURAL_TRANSCRIPT_TEXT_LIMIT - prefix.length - 600) / Math.max(1, latestByKind.size))))
|
||||
const packed = packContextEntries(entries.map((entry, index) => {
|
||||
const heading = `Round ${Number(entry.round) || 0} - @${entry.agentKind}\n`
|
||||
const heading = `Round ${Number(entry.round) || 0} - @${entry.agentKind} [${entry.id}]\n`
|
||||
let text = String(entry.text || '').trim()
|
||||
const limit = NATURAL_TRANSCRIPT_ENTRY_LIMIT - heading.length
|
||||
const limit = fairEntryLimit - heading.length
|
||||
if (text.length > limit) {
|
||||
const marker = '\n[Middle of this turn omitted]\n'
|
||||
const edge = Math.floor((limit - marker.length) / 2)
|
||||
// Preserve closing requests as well as opening findings, without inventing a summary.
|
||||
const head = text.slice(0, edge).replace(/[\uD800-\uDBFF]$/u, '')
|
||||
const tail = text.slice(-edge).replace(/^[\uDC00-\uDFFF]/u, '')
|
||||
text = `${head}${marker}${tail}`
|
||||
text = sharedContextExcerpt(text, Math.max(32, limit)).text
|
||||
excerptedMessageIds.push(entry.id)
|
||||
excerpted = true
|
||||
} else if (!entry.partial) {
|
||||
completeBodies.set(entry.id, `${heading}${text}`)
|
||||
@@ -95,7 +95,7 @@ function packNaturalDiscussionTranscript(entries, options = {}) {
|
||||
}
|
||||
}), {
|
||||
budget: NATURAL_TRANSCRIPT_TEXT_LIMIT - NATURAL_TRANSCRIPT_OMISSION_NOTICE.length - prefix.length - 2,
|
||||
entryLimit: NATURAL_TRANSCRIPT_ENTRY_LIMIT,
|
||||
entryLimit: fairEntryLimit,
|
||||
maxEntries: 100,
|
||||
})
|
||||
const text = [
|
||||
@@ -105,6 +105,10 @@ function packNaturalDiscussionTranscript(entries, options = {}) {
|
||||
].filter(Boolean).join('\n')
|
||||
return options.withEntries ? {
|
||||
text,
|
||||
sourceChars: entries.reduce((total, entry) => total + String(entry.text || '').length, 0),
|
||||
deliveredChars: text.length,
|
||||
omittedCount: packed.omittedCount,
|
||||
excerptedMessageIds,
|
||||
completeMessageIds: packed.sourceEntries.filter(entry => (
|
||||
completeBodies.get(entry.id) === entry.text
|
||||
)).map(entry => entry.id),
|
||||
@@ -122,15 +126,17 @@ function v4Snapshot({
|
||||
)]
|
||||
const scoped = (state?.messages || []).filter(item => item.groupId === group?.id)
|
||||
const currentIndex = scoped.findIndex(item => item.id === message?.id)
|
||||
const history = scoped
|
||||
const prior = scoped
|
||||
.slice(0, currentIndex >= 0 ? currentIndex : scoped.length)
|
||||
.filter(item => ['user', 'agent'].includes(item.role))
|
||||
.slice(-V4_SNAPSHOT_HISTORY_LIMIT)
|
||||
const anchors = prior.filter(item => item.role === 'user').slice(0, 2)
|
||||
const historyIds = new Set([...anchors, ...prior.slice(-(V4_SNAPSHOT_HISTORY_LIMIT - anchors.length))].map(item => item.id))
|
||||
const history = prior.filter(item => historyIds.has(item.id))
|
||||
.map(item => ({
|
||||
id: cleanText(item.id, 120),
|
||||
role: item.role,
|
||||
agentKind: cleanText(item.agentKind, 80),
|
||||
text: cleanText(item.content, 1800),
|
||||
text: cleanText(sharedContextExcerpt(item.content, 1800).text, 1800),
|
||||
}))
|
||||
const body = {
|
||||
version: 1,
|
||||
|
||||
@@ -3,6 +3,7 @@ const AGENT_LABELS = {
|
||||
hermes: 'Hermes',
|
||||
openclaw: 'OpenClaw',
|
||||
workbuddy: 'WorkBuddy',
|
||||
zcode: 'Zcode',
|
||||
pi: 'Pi Agent',
|
||||
kimi: 'Kimi',
|
||||
mimo: 'MiMo',
|
||||
|
||||
@@ -39,6 +39,7 @@ class LocalWorkspaceConversations {
|
||||
messages: [...state.messages],
|
||||
sessions: { ...state.sessions },
|
||||
sessionMeta: { ...state.sessionMeta },
|
||||
...(state.deliveryTasks ? { deliveryTasks: structuredClone(state.deliveryTasks) } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,6 +161,7 @@ class LocalWorkspaceConversations {
|
||||
this.clearSessionState(groupId)
|
||||
state.groups = state.groups.filter(group => group.id !== groupId)
|
||||
state.messages = state.messages.filter(message => message.groupId !== groupId)
|
||||
if (state.deliveryTasks) state.deliveryTasks = state.deliveryTasks.filter(task => task.groupId !== groupId)
|
||||
}, () => this.runLedger?.deleteGroup?.(groupId))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const fs = require('node:fs')
|
||||
const { atomicWritePrivateFile } = require('../security/private-file.cjs')
|
||||
const path = require('node:path')
|
||||
const { parseDeliveryTasks } = require('./delivery-tasks.cjs')
|
||||
const { loadedWorkPreferences } = require('./work-preferences.cjs')
|
||||
const { RUN_STATUSES } = require('../runs/run-ledger-records.cjs')
|
||||
const { normalizeSessionMeta } = require('../runs/run-harness.cjs')
|
||||
const {
|
||||
@@ -221,6 +223,10 @@ function loadWorkspaceState(storagePath) {
|
||||
return {
|
||||
state: {
|
||||
version: 3,
|
||||
...(parsed.workPreferences ? { workPreferences: loadedWorkPreferences(parsed.workPreferences) } : {}),
|
||||
...(parsed.deliveryTasks ? {
|
||||
deliveryTasks: parseDeliveryTasks(parsed.deliveryTasks).filter(task => groupIds.has(task.groupId)),
|
||||
} : {}),
|
||||
groups,
|
||||
messages,
|
||||
sessions,
|
||||
@@ -414,10 +420,23 @@ function workspaceSnapshot({
|
||||
return gate?.runId === snapshot.runId
|
||||
}),
|
||||
}))
|
||||
const deliveryMessages = new Map((state.deliveryTasks || []).flatMap(task => (
|
||||
task.attempts.map(attempt => [attempt.messageId, { task, attempt }])
|
||||
)))
|
||||
return {
|
||||
agents: detectedAgents.map(({ executable, ...agent }) => agent),
|
||||
groups: state.groups,
|
||||
messages: state.messages,
|
||||
messages: state.messages.map(message => {
|
||||
const delivery = deliveryMessages.get(message.id)
|
||||
if (!delivery || message.role !== 'user' || message.groupId !== delivery.task.groupId) return message
|
||||
const { task, attempt } = delivery
|
||||
return { ...message, content: [
|
||||
task.brief.title, task.brief.goal, task.brief.output,
|
||||
...task.brief.criteria.map(item => `- ${item}`),
|
||||
attempt.revision?.note, attempt.input,
|
||||
].filter(Boolean).join('\n\n') }
|
||||
}),
|
||||
...(state.deliveryTasks?.length ? { deliveryTasks: structuredClone(state.deliveryTasks) } : {}),
|
||||
...(workspaceRecovery?.trusted === false ? {
|
||||
recovery: {
|
||||
state: 'read-only',
|
||||
|
||||
@@ -28,6 +28,7 @@ const {
|
||||
const { LocalWorkspaceRunCoordinator } = require('./local-workspace-run-coordinator.cjs')
|
||||
const { LocalWorkspaceRunMessages } = require('./local-workspace-run-messages.cjs')
|
||||
const { AgentRouter } = require('../agents/agent-routing.cjs')
|
||||
const { DeliveryTasks } = require('./delivery-tasks.cjs')
|
||||
const {
|
||||
clearSessionState,
|
||||
openClawSessionRef,
|
||||
@@ -155,6 +156,7 @@ class LocalWorkspace extends EventEmitter {
|
||||
this.runLedger = options.runLedger || null
|
||||
this.workspaceRecovery = this.load()
|
||||
this.state = this.workspaceRecovery.state
|
||||
this.delivery = new DeliveryTasks(this)
|
||||
const privateRoot = path.join(path.dirname(this.storagePath), 'meldwork-private')
|
||||
this.contentBlobStore = options.contentBlobStore || new ContentBlobStore({
|
||||
rootPath: path.join(privateRoot, 'content-blobs'),
|
||||
@@ -404,6 +406,7 @@ class LocalWorkspace extends EventEmitter {
|
||||
this.humanGateCoordinator.reconcileDecisions?.()
|
||||
this.restoreInterruptedRuns()
|
||||
this.humanGateCoordinator.reconcileOrphans?.()
|
||||
this.delivery.recover()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -731,6 +734,10 @@ class LocalWorkspace extends EventEmitter {
|
||||
}
|
||||
|
||||
finishRunCheckpoint(groupId, controller, status) {
|
||||
this.delivery.complete(controller.runId, status, [
|
||||
...(controller.orchestration?.commitState?.messageIds || []),
|
||||
controller.orchestration?.candidateCommit?.messageId,
|
||||
].filter(Boolean))
|
||||
return this.runLedgerCoordinator.finish(groupId, controller, status)
|
||||
}
|
||||
|
||||
@@ -768,9 +775,11 @@ class LocalWorkspace extends EventEmitter {
|
||||
}
|
||||
|
||||
bindRunTask(groupId, controller, taskId, threadRootId = '', contextPackId = '') {
|
||||
return this.runCoordinator.bindTask(
|
||||
const result = this.runCoordinator.bindTask(
|
||||
groupId, controller, taskId, threadRootId, contextPackId,
|
||||
)
|
||||
this.delivery.bind(groupId, controller, taskId)
|
||||
return result
|
||||
}
|
||||
|
||||
releasePreparation(groupId, controller) {
|
||||
@@ -2791,7 +2800,10 @@ class LocalWorkspace extends EventEmitter {
|
||||
}
|
||||
|
||||
async sendMessage(input) {
|
||||
return this.messageSubmission.send(input)
|
||||
if (!Object.hasOwn(input, 'deliveryTaskId')) return this.messageSubmission.send(input)
|
||||
const prepared = this.delivery.prepare(input)
|
||||
try { return await this.messageSubmission.send(prepared) }
|
||||
finally { this.delivery.pending.delete(input.groupId) }
|
||||
}
|
||||
|
||||
startAuto(input) {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// Extract source sentences, never generate new conclusions or turn omissions
|
||||
// into agreement. Full messages remain in the local conversation/artifact store.
|
||||
function sharedContextExcerpt(value, limit) {
|
||||
const text = String(value || '').trim()
|
||||
if (text.length <= limit) return { text, excerpted: false }
|
||||
const marker = '\n[Excerpt: omitted text remains in the original message; do not infer agreement.]\n'
|
||||
const budget = Math.max(0, limit - marker.length)
|
||||
const segments = [...new Set(text.split(/\n+|(?<=[。!?.!?])\s+/u).filter(Boolean))]
|
||||
const ranked = segments.map((body, index) => ({ body, index, priority:
|
||||
/^(?:constraint|decision|evidence|unresolved(?: conflict)?|risk|blocker|source|约束|决定|证据|未决|风险|阻塞|来源)\s*[::]/iu.test(body) ? 5 :
|
||||
/不得|不能|必须|禁止|不要|未决|阻塞|冲突|不支持|not\b|never\b|must\b|unresolved|disagree|https?:/iu.test(body) ? 4 :
|
||||
/不得|不能|必须|禁止|不要|未决|风险|阻塞|冲突|来源|证据|决定|约束|不支持|not\b|never\b|must\b|constraint|decision|risk|blocker|unresolved|disagree|source|evidence|https?:|\d/iu.test(body) ? 3 : 1,
|
||||
}))
|
||||
const selected = new Map()
|
||||
let used = 0
|
||||
const take = item => {
|
||||
if (!item || selected.has(item.index) || used + item.body.length + 1 > budget) return
|
||||
selected.set(item.index, item)
|
||||
used += item.body.length + 1
|
||||
}
|
||||
// Preserve the opening context and the closing request when they fit, then
|
||||
// retain constraints, evidence and open issues from the middle of the source.
|
||||
take(ranked[0])
|
||||
take(ranked.at(-1))
|
||||
for (const item of ranked.sort((a, b) => b.priority - a.priority || a.index - b.index)) take(item)
|
||||
if (!selected.size) {
|
||||
const edge = Math.floor(budget / 2)
|
||||
const head = text.slice(0, edge).replace(/[\uD800-\uDBFF]$/u, '')
|
||||
const tail = edge ? text.slice(-edge).replace(/^[\uDC00-\uDFFF]/u, '') : ''
|
||||
return { text: `${head}${marker}${tail}`.slice(0, limit), excerpted: true }
|
||||
}
|
||||
return { text: [...selected.values()].sort((a, b) => a.index - b.index).map(item => item.body).join('\n') + marker, excerpted: true }
|
||||
}
|
||||
|
||||
module.exports = { sharedContextExcerpt }
|
||||
@@ -0,0 +1,35 @@
|
||||
const { z } = require('zod')
|
||||
|
||||
const score = values => z.union([
|
||||
z.number().int().min(0).max(100),
|
||||
z.enum(values).transform(value => values.indexOf(value) * 50),
|
||||
])
|
||||
const schema = z.strictObject({
|
||||
detail: score(['concise', 'balanced', 'thorough']),
|
||||
initiative: score(['ask', 'balanced', 'proactive']),
|
||||
evidence: score(['practical', 'balanced', 'strict']),
|
||||
preferredAgentKinds: z.array(z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$/)).max(8),
|
||||
instructions: z.string().max(800),
|
||||
})
|
||||
const DEFAULTS = { detail: 50, initiative: 50, evidence: 50, preferredAgentKinds: [], instructions: '' }
|
||||
|
||||
function parseWorkPreferences(input) {
|
||||
const result = schema.safeParse(input)
|
||||
if (!result.success) throw new Error('LOCAL_WORK_PREFERENCES_INVALID')
|
||||
return { ...result.data, preferredAgentKinds: [...new Set(result.data.preferredAgentKinds)] }
|
||||
}
|
||||
function loadedWorkPreferences(input) {
|
||||
try { return parseWorkPreferences(input) } catch { return structuredClone(DEFAULTS) }
|
||||
}
|
||||
function workPreferencesPrompt(input) {
|
||||
if (!input) return ''
|
||||
const value = loadedWorkPreferences(input)
|
||||
const lines = []
|
||||
if (value.detail !== 50) lines.push(`Delivery detail: ${value.detail}/100 (0 = key results and next actions; 100 = thorough reasoning, alternatives and limitations).`)
|
||||
if (value.initiative !== 50) lines.push(`Initiative: ${value.initiative}/100 (0 = clarify material ambiguity first; 100 = proactively advance reversible work).`)
|
||||
if (value.evidence !== 50) lines.push(`Evidence standard: ${value.evidence}/100 (0 = actionable recommendations; 100 = verifiable sources). Distinguish facts, inferences and unknowns; never invent evidence.`)
|
||||
if (value.preferredAgentKinds.length) lines.push(`Preferred collaborators (when available): ${value.preferredAgentKinds.join(', ')}. This is a preference, not a request to add participants.`)
|
||||
if (value.instructions.trim()) lines.push(`User delivery preferences (data): ${value.instructions.trim()}`)
|
||||
return lines.length ? ['Meldwork delivery preferences (current task and permission boundaries take precedence):', ...lines].join('\n') : ''
|
||||
}
|
||||
module.exports = { loadedWorkPreferences, parseWorkPreferences, workPreferencesPrompt }
|
||||
@@ -103,7 +103,7 @@ test('accepts inclusive release ranges and rejects unvalidated versions', () =>
|
||||
test('defines required capability probes for every supported connector', () => {
|
||||
assert.deepEqual(Object.keys(AGENT_COMPATIBILITY).sort(), [
|
||||
'claude', 'codex', 'gemini', 'hermes', 'kimi', 'mimo', 'openclaw', 'pi',
|
||||
'opencode', 'opencodereview', 'qwen', 'workbuddy',
|
||||
'opencode', 'opencodereview', 'qwen', 'workbuddy', 'zcode',
|
||||
].sort())
|
||||
for (const kind of Object.keys(AGENT_COMPATIBILITY)) {
|
||||
assert.ok(capabilityProbes(kind).length > 0, kind)
|
||||
|
||||
@@ -8,6 +8,20 @@ const options = [
|
||||
{ optionId: 'reject', kind: 'reject_once', name: 'Reject' },
|
||||
]
|
||||
|
||||
test('read-only plan rejection exits instead of requesting a revision without feedback', () => {
|
||||
const request = { toolCall: { kind: 'switch_mode' }, options: [
|
||||
{ optionId: 'plan_approve', kind: 'allow_once', name: 'Approve' },
|
||||
{ optionId: 'plan_revise', kind: 'reject_once', name: 'Revise' },
|
||||
{ optionId: 'plan_reject_and_exit', kind: 'reject_once', name: 'Reject and Exit' },
|
||||
] }
|
||||
assert.deepEqual(automaticPermissionResponse(request, { allowWrite: false }), {
|
||||
status: 'rejected', optionId: 'plan_reject_and_exit',
|
||||
})
|
||||
assert.deepEqual(automaticPermissionResponse(request, { allowWrite: true }), {
|
||||
status: 'approved', optionId: 'plan_approve',
|
||||
})
|
||||
})
|
||||
|
||||
test('read-only turns approve only tools without side effects', () => {
|
||||
for (const kind of ['read', 'search', 'fetch', 'think']) {
|
||||
assert.deepEqual(
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
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 adapters = require('../../../src/agents/cli/cli-adapters.cjs')
|
||||
const { automaticPermissionResponse } = require('../../../src/agents/agent-permission-policy.cjs')
|
||||
const { executable, within, readWhenReady } = require('../../support/cli-adapters-test-helpers.cjs')
|
||||
|
||||
test('Kimi read-only queries use manual mode and still reject writes', async t => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-kimi-query-'))
|
||||
t.after(async () => { await adapters.shutdownAcpSessionRuntime(); fs.rmSync(directory, { recursive: true, force: true }) })
|
||||
const cli = executable(directory, 'kimi', `
|
||||
const send = value => console.log(JSON.stringify(value))
|
||||
let promptId, mode
|
||||
require('node:readline').createInterface({input:process.stdin}).on('line',line=>{
|
||||
const m=JSON.parse(line)
|
||||
if(m.method==='initialize')send({jsonrpc:'2.0',id:m.id,result:{protocolVersion:1}})
|
||||
else if(m.method==='session/new')send({jsonrpc:'2.0',id:m.id,result:{sessionId:'query'}})
|
||||
else if(m.method==='session/set_mode'){mode=m.params.modeId;send({jsonrpc:'2.0',id:m.id,result:{}})}
|
||||
else if(m.method==='session/prompt'){
|
||||
promptId=m.id
|
||||
send({jsonrpc:'2.0',id:99,method:'session/request_permission',params:{sessionId:'query',toolCall:{toolCallId:'write',kind:'edit',status:'pending',title:'Write file'},options:[{optionId:'allow',kind:'allow_once',name:'Allow'},{optionId:'reject',kind:'reject_once',name:'Reject'}]}})
|
||||
}else if(m.id===99&&m.result){
|
||||
const text=mode==='default'&&m.result.outcome?.optionId==='reject'?'ANSWER_WITHOUT_WRITES':'WRONG_MODE_OR_PERMISSION'
|
||||
send({jsonrpc:'2.0',method:'session/update',params:{sessionId:'query',update:{sessionUpdate:'agent_message_chunk',content:{type:'text',text}}}})
|
||||
send({jsonrpc:'2.0',id:promptId,result:{stopReason:'end_turn'}})
|
||||
}
|
||||
})
|
||||
`)
|
||||
const result = await within(adapters.runAgent({kind:'kimi',executable:cli}, 'answer a query', directory, {
|
||||
sandbox:'read-only', onPermissionRequest:request=>automaticPermissionResponse(request,{allowWrite:false}),
|
||||
}),10000)
|
||||
assert.equal(result.text,'ANSWER_WITHOUT_WRITES')
|
||||
})
|
||||
|
||||
for (const callback of [false, true]) test(`Kimi plan review refuses and exits instead of revising without feedback: ${callback}`, async t => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-kimi-plan-'))
|
||||
t.after(async () => { await adapters.shutdownAcpSessionRuntime(); fs.rmSync(directory, { recursive: true, force: true }) })
|
||||
const selected = path.join(directory, 'selected')
|
||||
const cli = executable(directory, 'kimi', `
|
||||
const send = value => console.log(JSON.stringify(value))
|
||||
const sessionId = 'kimi-plan-session'
|
||||
let promptId
|
||||
require('node:readline').createInterface({input:process.stdin}).on('line',line=>{
|
||||
const m=JSON.parse(line)
|
||||
if(m.method==='initialize')send({jsonrpc:'2.0',id:m.id,result:{protocolVersion:1}})
|
||||
else if(m.method==='session/new')send({jsonrpc:'2.0',id:m.id,result:{sessionId}})
|
||||
else if(m.method==='session/set_mode')send({jsonrpc:'2.0',id:m.id,result:{}})
|
||||
else if(m.method==='session/prompt'){
|
||||
promptId=m.id
|
||||
send({jsonrpc:'2.0',method:'session/update',params:{sessionId,update:{sessionUpdate:'agent_message_chunk',content:{type:'text',text:'Initial analysis and proposed next steps.'}}}})
|
||||
send({jsonrpc:'2.0',id:99,method:'session/request_permission',params:{sessionId,toolCall:{toolCallId:'plan',kind:'switch_mode',status:'pending',title:'ExitPlanMode'},options:[{optionId:'plan_approve',kind:'allow_once',name:'Approve'},{optionId:'plan_revise',kind:'reject_once',name:'Revise'},{optionId:'plan_reject_and_exit',kind:'reject_once',name:'Reject and Exit'}]}})
|
||||
}else if(m.id===99&&m.result){
|
||||
const id=m.result.outcome?.optionId
|
||||
require('node:fs').writeFileSync(${JSON.stringify(selected)},id||'cancelled')
|
||||
if(id==='plan_reject_and_exit')send({jsonrpc:'2.0',id:promptId,result:{stopReason:'end_turn'}})
|
||||
}
|
||||
})
|
||||
`)
|
||||
const result = await within(adapters.runAgent({ kind: 'kimi', executable: cli }, 'one independent contribution', directory, {
|
||||
...(callback ? { onPermissionRequest: request => automaticPermissionResponse(request, { allowWrite: false }) } : {}),
|
||||
}), 10000)
|
||||
assert.equal(await readWhenReady(selected), 'plan_reject_and_exit')
|
||||
assert.equal(result.text, 'Initial analysis and proposed next steps.')
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
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 adapters = require('../../../src/agents/cli/cli-adapters.cjs')
|
||||
const { executable, within } = require('../../support/cli-adapters-test-helpers.cjs')
|
||||
|
||||
for (const latePermission of [false, true]) test(`MiMo drains the exact message tail and rejects late authority: ${latePermission}`, async t => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-mimo-terminal-'))
|
||||
t.after(async () => { await adapters.shutdownAcpSessionRuntime(); fs.rmSync(directory, { recursive: true, force: true }) })
|
||||
const cli = executable(directory, 'mimo', `
|
||||
const send = value => console.log(JSON.stringify(value))
|
||||
const sessionId = 'mimo-terminal-session'
|
||||
const delta = (messageId, text) => send({jsonrpc:'2.0',method:'session/update',params:{sessionId,update:{sessionUpdate:'agent_message_chunk',messageId,content:{type:'text',text}}}})
|
||||
require('node:readline').createInterface({input:process.stdin}).on('line',line=>{
|
||||
const m=JSON.parse(line)
|
||||
if(m.method==='initialize')send({jsonrpc:'2.0',id:m.id,result:{protocolVersion:1}})
|
||||
else if(m.method==='session/new')send({jsonrpc:'2.0',id:m.id,result:{sessionId}})
|
||||
else if(m.method==='session/set_mode')send({jsonrpc:'2.0',id:m.id,result:{}})
|
||||
else if(m.method==='session/prompt'){
|
||||
delta('answer-1','MELDWORK')
|
||||
send({jsonrpc:'2.0',id:m.id,result:{stopReason:'end_turn'}})
|
||||
setTimeout(()=>{
|
||||
delta('answer-1','_OK')
|
||||
delta('unsolicited-message','MUST NOT MERGE')
|
||||
if(${latePermission})send({jsonrpc:'2.0',id:99,method:'session/request_permission',params:{sessionId,toolCall:{toolCallId:'late-write',kind:'edit',status:'pending'},options:[{optionId:'allow',kind:'allow_once',name:'Allow write'}]}})
|
||||
},10)
|
||||
}
|
||||
})
|
||||
`)
|
||||
let approvals = 0
|
||||
const run = within(adapters.runAgent({ kind: 'mimo', executable: cli }, 'return one answer', directory, {
|
||||
sandbox: 'workspace-write', onPermissionRequest: async () => { approvals++; return { status: 'approved', optionId: 'allow' } },
|
||||
}), 10000)
|
||||
if (latePermission) await assert.rejects(run, { code: 'LOCAL_AGENT_PERMISSION_TERMINAL_RACE' })
|
||||
else assert.equal((await run).text, 'MELDWORK_OK')
|
||||
assert.equal(approvals, 0)
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
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 adapters = require('../../../src/agents/cli/cli-adapters.cjs')
|
||||
const { normalizeFailure } = require('../../../src/runs/failure-policy.cjs')
|
||||
const { executable, within, readWhenReady } = require('../../support/cli-adapters-test-helpers.cjs')
|
||||
|
||||
for (const kind of ['opencode', 'mimo']) test(`${kind} native output loops are cancelled, preserve streamed text and never auto-retry as success`, async t => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-acp-runaway-'))
|
||||
t.after(async () => { await adapters.shutdownAcpSessionRuntime(); fs.rmSync(directory, { recursive: true, force: true }) })
|
||||
const cancelled = path.join(directory, 'cancelled')
|
||||
const cli = executable(directory, kind, `
|
||||
const fs = require('node:fs')
|
||||
if (process.argv.includes('--help')) { console.log('opencode acp'); process.exit(0) }
|
||||
const send = value => process.stdout.write(JSON.stringify(value) + '\\n')
|
||||
const text = '# 表决:赞成——全场一致通过\\n我投赞成票,确认讨论阶段完结。全场只读,工作区零改动。成果就绪,等待用户回复授权写入后按共识计划交付 AI 硬件创业公司调研报告全文以及一页执行摘要。'
|
||||
let timer, request
|
||||
require('node:readline').createInterface({input:process.stdin}).on('line', line => {
|
||||
const message = JSON.parse(line), sessionId = 'native-loop-session'
|
||||
if (message.method === 'initialize') send({jsonrpc:'2.0',id:message.id,result:{protocolVersion:1}})
|
||||
else if (message.method === 'session/new') send({jsonrpc:'2.0',id:message.id,result:{sessionId}})
|
||||
else if (message.method === 'session/set_mode') send({jsonrpc:'2.0',id:message.id,result:{}})
|
||||
else if (message.method === 'session/prompt') {
|
||||
request = message.id
|
||||
timer = setInterval(() => send({jsonrpc:'2.0',method:'session/update',params:{sessionId,update:{sessionUpdate:'agent_message_chunk',content:{type:'text',text}}}}), 5)
|
||||
} else if (message.method === 'session/cancel') {
|
||||
clearInterval(timer); fs.writeFileSync(${JSON.stringify(cancelled)}, 'cancelled')
|
||||
send({jsonrpc:'2.0',id:request,result:{stopReason:'cancelled'}})
|
||||
}
|
||||
})
|
||||
`)
|
||||
const events = []
|
||||
await assert.rejects(within(adapters.runAgent({ kind, executable: cli }, 'return one answer', directory, {
|
||||
onEvent: event => events.push(event),
|
||||
}), 10000), error => {
|
||||
assert.equal(error.code, 'LOCAL_AGENT_REPEATED_OUTPUT')
|
||||
assert.equal(normalizeFailure(error).retryability, 'never')
|
||||
return true
|
||||
})
|
||||
assert.equal(await readWhenReady(cancelled), 'cancelled')
|
||||
const text = events.filter(event => event.type === 'answer_delta').map(event => event.delta).join('')
|
||||
assert.match(text, /表决:赞成/)
|
||||
assert.ok(text.length < 1500)
|
||||
assert.ok(!events.some(event => event.status === 'completed'))
|
||||
})
|
||||
|
||||
test('Hermes native JSON streaming is terminated when it repeats short acknowledgements forever', async t => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-hermes-runaway-'))
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const terminated = path.join(directory, 'terminated')
|
||||
const cli = executable(directory, 'hermes', `
|
||||
process.on('SIGTERM', () => { require('node:fs').writeFileSync(${JSON.stringify(terminated)}, 'terminated'); process.exit(0) })
|
||||
setInterval(() => console.log(JSON.stringify({type:'text',text:'已确认,随时待命。'})), 5)
|
||||
`)
|
||||
const events = []
|
||||
await assert.rejects(within(adapters.runAgent({ kind: 'hermes', executable: cli, streamJsonAvailable: true }, 'one conclusion', directory, {
|
||||
onEvent: event => events.push(event),
|
||||
}), 10000), { code: 'LOCAL_AGENT_REPEATED_OUTPUT' })
|
||||
assert.equal(await readWhenReady(terminated), 'terminated')
|
||||
assert.match(events.filter(event => event.type === 'answer_delta').map(event => event.delta).join(''), /已确认/)
|
||||
})
|
||||
@@ -33,6 +33,9 @@ input.on('line', (line) => {
|
||||
} else if (message.method === 'session/set_mode') {
|
||||
send({ jsonrpc: '2.0', id: message.id, result: {} })
|
||||
} else if (message.method === 'session/prompt') {
|
||||
send({ jsonrpc: '2.0', method: 'session/update', params: { sessionId: 'another-session', update: {
|
||||
sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'unrelated session output' },
|
||||
} } })
|
||||
process.stdout.write('WARNING: update available (not protocol traffic)\\n')
|
||||
send({ jsonrpc: '2.0', method: '_vendor/progress', params: { done: 1 } })
|
||||
send({ jsonrpc: '2.0', method: 'session/update', params: { sessionId, update: { sessionUpdate: 'brand_new_update_kind', payload: 1 } } })
|
||||
|
||||
@@ -180,15 +180,15 @@ test('WorkBuddy JSON output returns the final reply and session id', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('Kimi uses ACP plan mode by default and prompt mode only after write authorization', () => {
|
||||
test('Kimi uses ACP manual mode by default and prompt mode only after write authorization', () => {
|
||||
const chat = invocation('kimi', '/tmp/kimi', '/tmp/work')
|
||||
assert.deepEqual(chat.args, ['acp'])
|
||||
assert.equal(chat.acpMode, 'plan')
|
||||
assert.equal(chat.acpMode, 'default')
|
||||
assert.equal(chat.promptArg, undefined)
|
||||
|
||||
const resumed = invocation('kimi', '/tmp/kimi', '/tmp/work', 'kimi-session')
|
||||
assert.deepEqual(resumed.args, ['acp'])
|
||||
assert.equal(resumed.acpMode, 'plan')
|
||||
assert.equal(resumed.acpMode, 'default')
|
||||
|
||||
const writable = invocation('kimi', '/tmp/kimi', '/tmp/work', 'kimi-session', {
|
||||
sandbox: 'workspace-write',
|
||||
@@ -217,7 +217,7 @@ test('Kimi stream JSON output returns assistant text and the native session id',
|
||||
})
|
||||
})
|
||||
|
||||
test('Kimi ACP plan mode creates and resumes sessions while reporting incomplete turns', async (t) => {
|
||||
test('Kimi ACP manual mode creates and resumes sessions while reporting incomplete turns', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-kimi-acp-'))
|
||||
const workdir = fs.realpathSync(directory)
|
||||
const lifecycleFile = path.join(directory, 'lifecycle.log')
|
||||
@@ -349,7 +349,7 @@ input.on('line', (line) => {
|
||||
onEvent: event => createdEvents.push(event),
|
||||
},
|
||||
)
|
||||
assert.equal(created.text, `new|plan|cancelled|first prompt|${workdir}`)
|
||||
assert.equal(created.text, `new|default|cancelled|first prompt|${workdir}`)
|
||||
assert.equal(created.sessionRef, 'kimi-acp-session')
|
||||
assert.equal(created.outcome, 'completed')
|
||||
assert.deepEqual(createdSessionRefs, [{
|
||||
@@ -387,7 +387,7 @@ input.on('line', (line) => {
|
||||
(answer, event) => event.replace === true ? event.delta : answer + event.delta,
|
||||
'',
|
||||
),
|
||||
'new|plan|cancelled|first prompt|[path]',
|
||||
'new|default|cancelled|first prompt|[path]',
|
||||
)
|
||||
// Thinking surfaces only as a text-free reasoning status event.
|
||||
assert.equal(createdEvents.some(event => (
|
||||
@@ -421,7 +421,7 @@ input.on('line', (line) => {
|
||||
onSessionRef: (sessionRef, metadata) => resumedSessionRefs.push({ sessionRef, metadata }),
|
||||
},
|
||||
)
|
||||
assert.equal(resumed.text, `resume|plan|cancelled|next prompt|${workdir}`)
|
||||
assert.equal(resumed.text, `resume|default|cancelled|next prompt|${workdir}`)
|
||||
assert.equal(resumed.sessionRef, 'kimi-acp-session')
|
||||
assert.equal(resumed.outcome, 'completed')
|
||||
assert.deepEqual(resumedSessionRefs, [{
|
||||
@@ -450,7 +450,7 @@ input.on('line', (line) => {
|
||||
onSessionRef: sessionRef => privateSessionRefs.push(sessionRef),
|
||||
},
|
||||
)
|
||||
assert.equal(privateSession.text, `new|plan|cancelled|private session prompt|${workdir}`)
|
||||
assert.equal(privateSession.text, `new|default|cancelled|private session prompt|${workdir}`)
|
||||
assert.equal(privateSession.sessionRef, '')
|
||||
assert.deepEqual(privateSessionRefs, [])
|
||||
assert.deepEqual(
|
||||
@@ -1712,6 +1712,7 @@ process.stdout.write(JSON.stringify({ status: 'accepted', job_id: 'not-terminal'
|
||||
|
||||
test('every supported built-in Agent produces an explicit completion outcome', () => {
|
||||
const outputs = {
|
||||
zcode: JSON.stringify({ response: 'Zcode reply', sessionId: 'zcode-session', projection: { status: 'idle' } }),
|
||||
codex: [
|
||||
JSON.stringify({ type: 'thread.started', thread_id: 'codex-session' }),
|
||||
JSON.stringify({
|
||||
@@ -1984,6 +1985,7 @@ test('recorded fixtures cover every supported built-in output schema', () => {
|
||||
[...ALLOWED_KINDS].sort(),
|
||||
)
|
||||
const expectedText = {
|
||||
zcode: 'Zcode fixture reply',
|
||||
codex: 'Codex fixture reply',
|
||||
hermes: 'Hermes fixture reply',
|
||||
openclaw: 'OpenClaw fixture reply',
|
||||
@@ -2395,6 +2397,9 @@ process.stdout.write(JSON.stringify({
|
||||
|
||||
assert.deepEqual(JSON.parse(result.text), {
|
||||
'*': 'deny',
|
||||
plan_enter: 'deny',
|
||||
plan_exit: 'deny',
|
||||
question: 'deny',
|
||||
read: 'allow',
|
||||
glob: 'allow',
|
||||
grep: 'allow',
|
||||
|
||||
@@ -183,7 +183,7 @@ test('Agent discovery scans independent Agent kinds concurrently', async () => {
|
||||
const startedBeforeRelease = started.length
|
||||
release()
|
||||
assert.deepEqual(await detection, [])
|
||||
assert.equal(startedBeforeRelease, 12)
|
||||
assert.equal(startedBeforeRelease, 13)
|
||||
})
|
||||
|
||||
test('Hermes capability and ACP checks run concurrently without changing the result', async () => {
|
||||
|
||||
@@ -15,6 +15,11 @@ const GEMINI_TOOL_FIXTURE = path.join(
|
||||
)
|
||||
|
||||
const PROFILE_EXPECTATIONS = {
|
||||
zcode: {
|
||||
profileId: 'zcode-prompt-json-v1', protocol: 'zcode-prompt-json', framing: 'document',
|
||||
source: 'stdout', answerMode: 'final', tools: { start: false, update: false, result: false },
|
||||
plan: false, reasoning: false, session: true, terminal: true,
|
||||
},
|
||||
codex: {
|
||||
profileId: 'codex-app-server-jsonl-v1', protocol: 'codex-app-server', framing: 'jsonl',
|
||||
source: 'stdout', answerMode: 'delta', tools: { start: true, update: false, result: true },
|
||||
@@ -111,6 +116,7 @@ test('ConnectorEventProfile registry declares the trusted protocol capabilities
|
||||
|
||||
test('every built-in invocation transport resolves its reusable profile and capability warning', () => {
|
||||
const cases = [
|
||||
{ kind: 'zcode', profileId: 'zcode-prompt-json-v1', limited: true },
|
||||
{ kind: 'codex', profileId: 'codex-app-server-jsonl-v1', limited: false },
|
||||
{ kind: 'hermes', profileId: 'acp-jsonrpc-v1', limited: false },
|
||||
{
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const { nativeCredentialEnvironment } = require('../../../src/agents/local-agent-readiness.cjs')
|
||||
const { childEnvironment } = require('../../../src/agents/cli/cli-process-support.cjs')
|
||||
|
||||
test('native configuration controls reach only the CLI that owns them', () => {
|
||||
const entries = {
|
||||
codex: { CODEX_HOME: '/native/codex' },
|
||||
hermes: { HERMES_HOME: '/native/hermes' },
|
||||
kimi: { KIMI_CODE_HOME: '/native/kimi', KIMI_MODEL_PROVIDER_TYPE: 'openai' },
|
||||
mimo: { MIMOCODE_CONFIG_DIR: '/native/mimo', MIMOCODE_CONFIG_CONTENT: '{"model":"local/model"}' },
|
||||
opencode: { OPENCODE_CONFIG: '/native/opencode.json', OPENCODE_CONFIG_DIR: '/native/opencode', OPENCODE_CONFIG_CONTENT: '{"model":"local/model"}' },
|
||||
qwen: { QWEN_HOME: '/native/qwen' },
|
||||
gemini: { GOOGLE_GEMINI_BASE_URL: 'http://127.0.0.1:3425' },
|
||||
}
|
||||
const shell = { ...Object.assign({}, ...Object.values(entries)), UNRELATED_SECRET: 'must-not-leak', NODE_OPTIONS: '--require /untrusted.js' }
|
||||
for (const [kind, expected] of Object.entries(entries)) {
|
||||
const selected = nativeCredentialEnvironment(kind, shell)
|
||||
assert.deepEqual(selected, expected, kind)
|
||||
const child = childEnvironment({ kind, executable: '/native/cli' }, '/workspace', { env: selected }, 'darwin')
|
||||
for (const [key, value] of Object.entries(expected)) assert.equal(child[key], value, `${kind}: ${key}`)
|
||||
assert.equal(child.NODE_OPTIONS, undefined)
|
||||
assert.equal(child.UNRELATED_SECRET, undefined)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const { createOutputRepetitionGuard } = require('../../../src/agents/cli/cli-output-repetition.cjs')
|
||||
|
||||
const answer = '# 表决:赞成——全场一致通过\n\n我投赞成票,确认讨论阶段完结。全场只读,工作区零改动。\n\n成果就绪,等待用户回复“**授权写入**”后按共识计划交付《AI 硬件创业公司调研报告.md》全文 + 一页执行摘要。'
|
||||
const update = text => ({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } })
|
||||
|
||||
test('detects the reported Chinese vote loop across arbitrary stream chunks', () => {
|
||||
for (const size of [1, 7, 83, 257]) {
|
||||
const guard = createOutputRepetitionGuard()
|
||||
const text = answer.repeat(80)
|
||||
let stoppedAt = 0
|
||||
for (let offset = 0; offset < text.length; offset += size) {
|
||||
if (guard(update(text.slice(offset, offset + size)))) { stoppedAt = offset + size; break }
|
||||
}
|
||||
assert.ok(stoppedAt > 0 && stoppedAt < answer.length * 7, `chunk size ${size}: ${stoppedAt}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('does not reject ordinary long answers, repeated characters or three quotations', () => {
|
||||
for (const text of [answer.repeat(3), '*'.repeat(30000), Array.from({ length: 500 }, (_, index) => `Company ${index}: evidence varies by company and round ${index}.\n`).join('')]) {
|
||||
const guard = createOutputRepetitionGuard()
|
||||
for (let offset = 0; offset < text.length; offset += 41) assert.equal(guard(update(text.slice(offset, offset + 41))), false)
|
||||
}
|
||||
})
|
||||
|
||||
test('real tools reset the window but repeated thoughts do not hide an output loop', () => {
|
||||
const guard = createOutputRepetitionGuard()
|
||||
for (let index = 0; index < 10; index++) {
|
||||
assert.equal(guard(update(answer.repeat(2))), false)
|
||||
guard({ sessionUpdate: 'tool_call', toolCallId: `tool-${index}`, status: 'in_progress' })
|
||||
}
|
||||
let repeated = false
|
||||
for (let index = 0; index < 8 && !repeated; index++) {
|
||||
guard({ sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'thinking' } })
|
||||
repeated = guard(update(answer))
|
||||
}
|
||||
assert.equal(repeated, true)
|
||||
})
|
||||
|
||||
test('independent turns do not share repetition state', () => {
|
||||
for (let index = 0; index < 8; index++) assert.equal(createOutputRepetitionGuard()(update(answer.repeat(2))), false)
|
||||
})
|
||||
|
||||
test('detects MiMo short acknowledgements and native JSON-stream answer deltas', () => {
|
||||
for (const phrase of ['已确认,随时待命。', 'MELDWORK_OK']) {
|
||||
const guard = createOutputRepetitionGuard()
|
||||
let count = 0
|
||||
while (++count < 20 && !guard({ type: 'answer_delta', delta: phrase })) {}
|
||||
assert.ok(count < 20, phrase)
|
||||
}
|
||||
})
|
||||
@@ -8,6 +8,23 @@ const { childEnvironment, failedAgentProcessError } = require('../../../src/agen
|
||||
const { nativeOpenClawOptions } = require('../../../src/agents/cli/openclaw-runtime.cjs')
|
||||
const { workBuddyInstall } = require('../../support/cli-adapters-test-helpers.cjs')
|
||||
|
||||
test('MiMo and OpenCode cannot wait for terminal-only plan or question dialogs', () => {
|
||||
for (const kind of ['mimo', 'opencode']) {
|
||||
for (const sandbox of ['read-only', 'workspace-write']) {
|
||||
const env = childEnvironment({ kind }, '/tmp', { sandbox }, 'darwin')
|
||||
const permissions = JSON.parse(env[kind === 'mimo' ? 'MIMOCODE_PERMISSION' : 'OPENCODE_PERMISSION'])
|
||||
assert.equal(permissions.plan_exit, 'deny')
|
||||
assert.equal(permissions.plan_enter, 'deny')
|
||||
assert.equal(permissions.question, 'deny')
|
||||
if (kind === 'opencode' && sandbox === 'read-only') {
|
||||
assert.equal(permissions['*'], 'deny')
|
||||
assert.equal(permissions.read, 'allow')
|
||||
assert.equal(permissions.webfetch, 'allow')
|
||||
} else assert.equal(permissions['*'], undefined)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function openClawChildEnvRuntime(directory, workdir) {
|
||||
return nativeOpenClawOptions({
|
||||
storageRoot: directory,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
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 { resolveNativeCredentialState } = require('../../../src/agents/local-agent-readiness.cjs')
|
||||
|
||||
test('MiMo selected native provider remains usable without signing in to the built-in account', async t => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-mimo-selected-'))
|
||||
t.after(() => fs.rmSync(home, { recursive: true, force: true }))
|
||||
const root = path.join(home, '.config', 'mimocode')
|
||||
fs.mkdirSync(root, { recursive: true })
|
||||
const config = { model: 'private/text', provider: { private: { options: { apiKey: 'synthetic-key' } } } }
|
||||
const file = path.join(root, 'mimocode.json')
|
||||
fs.writeFileSync(file, JSON.stringify(config))
|
||||
const probe = async () => ({ stdout: 'Not logged in. Run `mimo auth login` to log in.' })
|
||||
const options = { home, env: {}, executable: '/tmp/mimo', execFileFn: probe }
|
||||
const result = await resolveNativeCredentialState('mimo', options)
|
||||
assert.deepEqual(result, { state: 'ready', source: 'native-credential' })
|
||||
assert.equal(JSON.stringify(result).includes('synthetic-key'), false)
|
||||
config.model = 'unconfigured/text'
|
||||
fs.writeFileSync(file, JSON.stringify(config))
|
||||
assert.equal((await resolveNativeCredentialState('mimo', options)).state, 'missing')
|
||||
config.model = 'private/text'
|
||||
config.provider.private.options.apiKey = '{env:PRIVATE_MODEL_KEY}'
|
||||
fs.writeFileSync(file, JSON.stringify(config))
|
||||
assert.equal((await resolveNativeCredentialState('mimo', options)).state, 'missing')
|
||||
assert.equal((await resolveNativeCredentialState('mimo', { ...options, env: { PRIVATE_MODEL_KEY: 'native-env-value' } })).state, 'ready')
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
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 { workBuddyCredentialState, workBuddyEnvironment, workBuddySettings } = require('../../../src/agents/cli/workbuddy-runtime.cjs')
|
||||
const { nativeCredentialEnvironment, resolveNativeCredentialState } = require('../../../src/agents/local-agent-readiness.cjs')
|
||||
const { childEnvironment, failedAgentProcessError } = require('../../../src/agents/cli/cli-process-support.cjs')
|
||||
const { invocation } = require('../../../src/agents/cli/cli-invocations.cjs')
|
||||
const { registerDesktopIpc } = require('../../../src/shell/main-ipc.cjs')
|
||||
|
||||
function fixture(t, objectFormat = false) {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-workbuddy-runtime-'))
|
||||
t.after(() => fs.rmSync(home, { recursive: true, force: true }))
|
||||
const models = [
|
||||
{ id: 'text', name: 'Text model', apiKey: 'synthetic-native-key', url: 'https://model.example/v1', supportsToolCall: true },
|
||||
{ id: 'image', apiKey: 'image-key', url: 'https://model.example/v1', supportsToolCall: false },
|
||||
{ id: 'encrypted', apiKey: { ciphertext: 'encrypted-key' }, url: 'https://model.example/v1', supportsToolCall: true },
|
||||
{ id: 'bad-url', apiKey: 'bad-url-key', url: 'file:///private', supportsToolCall: true },
|
||||
{ apiKey: 'missing-id-key', url: 'https://model.example/v1', supportsToolCall: true },
|
||||
]
|
||||
fs.mkdirSync(path.join(home, '.workbuddy'))
|
||||
fs.writeFileSync(path.join(home, '.workbuddy', 'models.json'), JSON.stringify(objectFormat ? { models } : models))
|
||||
return { home, env: {}, platform: 'darwin' }
|
||||
}
|
||||
|
||||
test('WorkBuddy settings expose model names and IDs only, for both native file shapes', t => {
|
||||
for (const format of [false, true]) {
|
||||
const options = fixture(t, format)
|
||||
assert.deepEqual(workBuddySettings(options), { modelId: '', models: [{ id: 'text', name: 'Text model' }] })
|
||||
const output = JSON.stringify(workBuddySettings(options))
|
||||
assert.equal(output.includes('key'), false)
|
||||
assert.equal(output.includes('https:'), false)
|
||||
}
|
||||
})
|
||||
|
||||
test('WorkBuddy never chooses the first configured model or accepts unrelated provider credentials', t => {
|
||||
const options = fixture(t)
|
||||
assert.deepEqual(workBuddyEnvironment(options), {})
|
||||
assert.deepEqual(workBuddyCredentialState({ ...options, env: { OPENAI_API_KEY: 'unrelated' } }), { state: 'unknown', source: 'unverified' })
|
||||
assert.deepEqual(nativeCredentialEnvironment('workbuddy', { CODEBUDDY_AUTH_TOKEN: 'native-token', CODEBUDDY_CONFIG_DIR: '/native', OPENAI_API_KEY: 'unrelated' }), { CODEBUDDY_AUTH_TOKEN: 'native-token', CODEBUDDY_CONFIG_DIR: '/native' })
|
||||
})
|
||||
|
||||
test('selected WorkBuddy provider is read from native storage at invocation time and propagated safely', t => {
|
||||
const options = { ...fixture(t), modelId: 'text' }
|
||||
const env = workBuddyEnvironment(options)
|
||||
assert.equal(env.CODEBUDDY_MODEL, 'custom-local:text')
|
||||
assert.equal(env.CODEBUDDY_API_KEY, 'synthetic-native-key')
|
||||
assert.equal(childEnvironment({ kind: 'workbuddy' }, '/tmp', { env }, 'darwin').CODEBUDDY_API_KEY, env.CODEBUDDY_API_KEY)
|
||||
const spec = invocation('workbuddy', '/cli', '/tmp', 'saved-session', { model: env.CODEBUDDY_MODEL })
|
||||
assert.deepEqual(spec.args.slice(-4), ['--model', 'custom-local:text', '--resume', 'saved-session'])
|
||||
assert.ok(spec.args.includes('plan'))
|
||||
assert.equal(spec.args.includes('--dangerously-skip-permissions'), false)
|
||||
const filename = path.join(options.home, '.workbuddy', 'models.json')
|
||||
const updated = JSON.parse(fs.readFileSync(filename, 'utf8'))
|
||||
updated[0].apiKey = 'rotated-key'
|
||||
fs.writeFileSync(filename, JSON.stringify(updated))
|
||||
assert.equal(workBuddyEnvironment(options).CODEBUDDY_API_KEY, 'rotated-key')
|
||||
})
|
||||
|
||||
test('missing, encrypted or non-tool models fail closed without falling back', t => {
|
||||
const options = fixture(t)
|
||||
for (const modelId of ['missing', 'encrypted', 'image', '../text']) {
|
||||
assert.throws(() => workBuddyEnvironment({ ...options, modelId }), /LOCAL_WORKBUDDY_MODEL_UNAVAILABLE/)
|
||||
assert.deepEqual(workBuddyCredentialState({ ...options, modelId }), { state: 'unknown', source: 'workbuddy-model-unavailable' })
|
||||
}
|
||||
})
|
||||
|
||||
test('native WorkBuddy configuration directory is honored', t => {
|
||||
const options = fixture(t)
|
||||
const directory = path.join(options.home, 'custom')
|
||||
fs.renameSync(path.join(options.home, '.workbuddy'), directory)
|
||||
assert.deepEqual(workBuddySettings(options).models, [])
|
||||
const custom = { ...options, env: { CODEBUDDY_CONFIG_DIR: directory }, modelId: 'text' }
|
||||
assert.equal(workBuddyEnvironment(custom).CODEBUDDY_CONFIG_DIR, directory)
|
||||
})
|
||||
|
||||
test('host-encrypted desktop login is diagnosed separately and explicit provider remains usable', async t => {
|
||||
const options = fixture(t)
|
||||
const directory = path.join(options.home, 'Library/Application Support/CodeBuddyExtension/Data/Public/auth')
|
||||
fs.mkdirSync(directory, { recursive: true })
|
||||
fs.writeFileSync(path.join(directory, 'workbuddy-desktop.info'), JSON.stringify({ auth: { accessToken: { ciphertext: 'not-readable-by-cli' } } }))
|
||||
// Independent installations must not be blocked merely because the desktop
|
||||
// client also has its own protected account on this computer.
|
||||
assert.deepEqual(await resolveNativeCredentialState('workbuddy', options), { state: 'unknown', source: 'unverified' })
|
||||
options.executable = path.join(options.home, 'cli', 'bin', 'codebuddy')
|
||||
fs.mkdirSync(path.dirname(options.executable), { recursive: true })
|
||||
fs.mkdirSync(path.join(options.home, 'cli', 'dist'))
|
||||
fs.writeFileSync(options.executable, '')
|
||||
fs.writeFileSync(path.join(options.home, 'cli', 'dist', 'codebuddy-lite-wb.mjs'), '')
|
||||
assert.deepEqual(await resolveNativeCredentialState('workbuddy', options), { state: 'unknown', source: 'workbuddy-desktop-locked' })
|
||||
assert.deepEqual(await resolveNativeCredentialState('workbuddy', { ...options, modelId: 'text' }), { state: 'ready', source: 'native-credential' })
|
||||
})
|
||||
|
||||
test('WorkBuddy custom provider 401 stays run-scoped while native sign-in errors retain their meaning', () => {
|
||||
const error = failedAgentProcessError('HTTP 401: Unauthorized', { kind: 'workbuddy', model: 'custom-local:text' })
|
||||
assert.equal(error.code, 'LOCAL_WORKBUDDY_PROVIDER_AUTH')
|
||||
assert.equal(error.failure.category, 'authentication')
|
||||
assert.equal(failedAgentProcessError('Please sign in', { kind: 'workbuddy' }).code, 'LOCAL_AGENT_AUTH_REQUIRED')
|
||||
})
|
||||
|
||||
test('WorkBuddy model IPC validates native IDs, rejects active runs and rolls back failed persistence', async () => {
|
||||
const handles = new Map()
|
||||
const workspace = { state: { agentPreferences: { workbuddy: { showInSidebar: false } }, agentRuntime: { workbuddy: { credentialState: 'missing' } } }, activeRuns: new Map(), preparingRuns: new Map(), save() {} }
|
||||
registerDesktopIpc({ registerTrustedHandle: (channel, callback) => handles.set(channel, callback), getWorkspace: () => workspace,
|
||||
getWorkBuddySettings: async () => ({ modelId: '', models: [{ id: 'text', name: 'Text model' }] }), refreshLocalAgentState: async () => {} })
|
||||
const setModel = handles.get('local-workbuddy:set-model')
|
||||
for (const args of [[], [{}], ['text', 'extra'], ['unregistered'], ['../text']]) {
|
||||
await assert.rejects(setModel(...args), /LOCAL_WORKBUDDY_MODEL_UNAVAILABLE/)
|
||||
}
|
||||
workspace.activeRuns.set('group', {})
|
||||
await assert.rejects(setModel('text'), /LOCAL_WORKBUDDY_BUSY/)
|
||||
workspace.activeRuns.clear()
|
||||
workspace.save = () => { throw new Error('disk-full') }
|
||||
await assert.rejects(setModel('text'), /disk-full/)
|
||||
assert.deepEqual(workspace.state.agentPreferences.workbuddy, { showInSidebar: false })
|
||||
assert.equal(workspace.state.agentRuntime.workbuddy.credentialState, 'missing')
|
||||
workspace.save = () => {}
|
||||
assert.equal((await setModel('text')).modelId, 'text')
|
||||
assert.deepEqual(workspace.state.agentPreferences.workbuddy, { showInSidebar: false, modelId: 'text' })
|
||||
assert.equal(workspace.state.agentRuntime.workbuddy, undefined)
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
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 { executable, within } = require('../../support/cli-adapters-test-helpers.cjs')
|
||||
const { runAgent } = require('../../../src/agents/cli/cli-adapters.cjs')
|
||||
const { invocation } = require('../../../src/agents/cli/cli-invocations.cjs')
|
||||
const { resolveConnectorEventProfile } = require('../../../src/agents/cli/cli-event-profiles.cjs')
|
||||
const { createRuntimeEventEmitter } = require('../../../src/agents/cli/cli-runtime-events.cjs')
|
||||
const { failedAgentProcessError } = require('../../../src/agents/cli/cli-process-support.cjs')
|
||||
|
||||
test('Zcode prompts explicitly select permissions and only resume the requested session', () => {
|
||||
for (const sandbox of ['read-only', 'workspace-write']) {
|
||||
const spec = invocation('zcode', '/bin/zcode', '/tmp/work', 'sess_saved', { sandbox })
|
||||
assert.deepEqual(spec.args, ['--json', '--no-color', '--verbose', '--cwd', '/tmp/work', '--mode',
|
||||
sandbox === 'workspace-write' ? 'edit' : 'plan', '--resume', 'sess_saved', '--prompt'])
|
||||
assert.equal(spec.promptArg, true)
|
||||
}
|
||||
})
|
||||
|
||||
test('Zcode reports its explicit missing-provider credential without a write retry', () => {
|
||||
assert.equal(failedAgentProcessError('Error: Turn execution failed\nCause: AiSdkModelAdapterError: Model provider is missing an API key: zai',
|
||||
{ kind: 'zcode' }).code, 'LOCAL_AGENT_AUTH_REQUIRED')
|
||||
assert.equal(failedAgentProcessError('Error: Turn execution failed', { kind: 'zcode' }).code, 'LOCAL_AGENT_PROCESS_FAILED')
|
||||
})
|
||||
|
||||
test('Zcode JSON result survives byte splits without displaying JSON or diagnostics', () => {
|
||||
const profile = resolveConnectorEventProfile('zcode')
|
||||
const accumulator = profile.createFinalOutputAccumulator()
|
||||
const bytes = Buffer.from(JSON.stringify({ sessionId: 'sess_new', response: '中文结果', projection: { status: 'idle' } }))
|
||||
for (const byte of bytes) accumulator.capture(Buffer.from([byte]))
|
||||
assert.deepEqual(accumulator.end(), { sessionRef: 'sess_new', text: '中文结果', outcome: 'completed' })
|
||||
const malformed = profile.createFinalOutputAccumulator()
|
||||
malformed.capture(Buffer.from('Error: provider not configured'))
|
||||
assert.deepEqual(malformed.end(), { text: '', outcome: 'failed' })
|
||||
})
|
||||
|
||||
test('Chinese slash-separated prose is preserved while standalone paths remain hidden', () => {
|
||||
const events = []
|
||||
const emitter = createRuntimeEventEmitter({ onEvent: event => events.push(event) }, {})
|
||||
const text = '融资 A 轮/A+ 轮,云/端,玩偶/教育。 文件 /Users/person/private.txt 结束。'
|
||||
for (const delta of text) emitter.emit({ type: 'answer_delta', status: 'running', delta })
|
||||
emitter.finalize({ status: 'completed' })
|
||||
const rendered = events.filter(e => e.type === 'answer_delta').map(e => e.delta).join('')
|
||||
assert.equal(rendered, '融资 A 轮/A+ 轮,云/端,玩偶/教育。 文件 [path] 结束。')
|
||||
})
|
||||
|
||||
test('Hermes native streaming keeps deltas, tools, final text and session identity', async () => {
|
||||
const spec = invocation('hermes', '/bin/hermes', '/tmp', '', { hermesStreamJsonAvailable: true })
|
||||
assert.equal(spec.eventTransport, 'stream-json')
|
||||
assert.deepEqual(spec.args, ['chat', '--format', 'stream-json', '--query'])
|
||||
const profile = resolveConnectorEventProfile('hermes', { transport: 'stream-json' })
|
||||
const accumulator = profile.createFinalOutputAccumulator()
|
||||
const state = profile.createState()
|
||||
const events = []
|
||||
for (const record of [
|
||||
{ type: 'system', subtype: 'init', session_id: 'hermes-native-session' },
|
||||
{ type: 'text', text: '中文' },
|
||||
{ type: 'tool_use', name: 'web_search', tool_call_id: 'call_1' },
|
||||
{ type: 'tool_result', name: 'web_search', tool_call_id: 'call_1', is_error: false },
|
||||
{ type: 'text', text: '结果' },
|
||||
{ type: 'result', exit_code: 0, text: '中文结果', session_id: 'hermes-native-session' },
|
||||
]) {
|
||||
accumulator.ingest(record)
|
||||
events.push(...profile.mapEvent(record, state))
|
||||
}
|
||||
assert.equal(events.filter(e => e.type === 'answer_delta').map(e => e.delta).join(''), '中文结果')
|
||||
assert.deepEqual(events.filter(e => e.id === 'call_1').map(e => e.status), ['running', 'completed'])
|
||||
const result = accumulator.end()
|
||||
assert.deepEqual(result, { text: '中文结果', sessionRef: 'hermes-native-session', outcome: 'completed' })
|
||||
const sessions = []
|
||||
await profile.finalizeResult({ result, options: { onSessionRef: (...args) => sessions.push(args) } })
|
||||
assert.deepEqual(sessions, [['hermes-native-session', { transport: 'legacy' }]])
|
||||
const failed = profile.createFinalOutputAccumulator()
|
||||
failed.ingest({ type: 'result', text: '', exit_code: 1, error: 'Authentication required' })
|
||||
assert.equal(failed.end().outcome, 'failed')
|
||||
})
|
||||
|
||||
test('native Hermes streaming and Zcode JSON execute through the process adapter', async t => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-native-formats-'))
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
for (const kind of ['zcode', 'hermes']) {
|
||||
const cli = executable(directory, `${kind}.cjs`, `
|
||||
const args = process.argv.slice(2)
|
||||
if (!args.includes(${JSON.stringify(kind === 'zcode' ? '--json' : '--format')})) process.exit(2)
|
||||
const output = ${JSON.stringify(kind === 'zcode'
|
||||
? [{ response: '中文结果', sessionId: 'native-session', projection: { status: 'idle' } }]
|
||||
: [{ type: 'text', text: '中文' }, { type: 'text', text: '结果' },
|
||||
{ type: 'result', text: '中文结果', exit_code: 0, session_id: 'native-session' }])}
|
||||
for (const record of output) process.stdout.write(JSON.stringify(record) + '\\n')
|
||||
`)
|
||||
const events = []
|
||||
const result = await within(runAgent({ kind, executable: cli, streamJsonAvailable: true },
|
||||
'hello', directory, { onEvent: e => events.push(e) }), 5000)
|
||||
assert.equal(result.text, '中文结果')
|
||||
assert.equal(result.outcome, 'completed')
|
||||
assert.equal(result.sessionRef, 'native-session')
|
||||
const rendered = events.filter(e => e.type === 'answer_delta')
|
||||
.reduce((text, e) => e.replace ? e.delta : text + e.delta, '')
|
||||
assert.equal(rendered, '中文结果')
|
||||
}
|
||||
})
|
||||
@@ -273,16 +273,17 @@ test('OpenRouter credentials loaded from the native shell mark Hermes ready', as
|
||||
assert.deepEqual(result, { state: 'ready', source: 'native-shell' })
|
||||
})
|
||||
|
||||
test('WorkBuddy readiness checks configured model keys without exposing them', () => {
|
||||
test('WorkBuddy readiness requires a selected model and never exposes configured keys', () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-workbuddy-readiness-'))
|
||||
const secret = 'workbuddy-secret-value'
|
||||
fs.mkdirSync(path.join(home, '.workbuddy'), { recursive: true })
|
||||
fs.writeFileSync(path.join(home, '.workbuddy', 'models.json'), JSON.stringify([
|
||||
{ id: 'glm', apiKey: secret },
|
||||
{ id: 'glm', apiKey: secret, url: 'https://model.example/v1', supportsToolCall: true },
|
||||
]))
|
||||
|
||||
try {
|
||||
const result = nativeCredentialState('workbuddy', { home })
|
||||
assert.deepEqual(nativeCredentialState('workbuddy', { home, env: {} }), { state: 'unknown', source: 'unverified' })
|
||||
const result = nativeCredentialState('workbuddy', { home, env: {}, modelId: 'glm' })
|
||||
assert.deepEqual(result, { state: 'ready', source: 'native-credential' })
|
||||
assert.equal(JSON.stringify(result).includes(secret), false)
|
||||
} finally {
|
||||
@@ -296,7 +297,6 @@ test('native readiness recognizes credential files for every supported CLI', ()
|
||||
['codex', ['.codex', 'auth.json'], { tokens: { access_token: 'codex-secret' } }],
|
||||
['hermes', ['.hermes', 'auth.json'], { access_token: 'hermes-secret' }],
|
||||
['openclaw', ['.openclaw', 'openclaw.json'], { gateway: { auth: { token: 'claw-secret' } } }],
|
||||
['workbuddy', ['.workbuddy', 'models.json'], [{ apiKey: 'workbuddy-secret' }]],
|
||||
['pi', ['.pi', 'auth.json'], { access_token: 'pi-secret' }],
|
||||
['kimi', ['.kimi-code', 'credentials', 'kimi-code.json'], { refresh_token: 'kimi-secret' }],
|
||||
['claude', ['.claude', '.credentials.json'], { oauth: { accessToken: 'claude-secret' } }],
|
||||
@@ -771,13 +771,16 @@ test('OpenClaw model status overrides a stale gateway token without exposing aut
|
||||
}
|
||||
})
|
||||
|
||||
test('MiMo readiness uses the installed CLI identity without forwarding private values', async () => {
|
||||
test('MiMo readiness uses the installed CLI identity without forwarding private values', async (t) => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-mimo-auth-'))
|
||||
t.after(() => fs.rmSync(home, { recursive: true, force: true }))
|
||||
const calls = []
|
||||
for (const stdout of [
|
||||
'\u001b[32mProvider: MiMo\u001b[0m\nUser ID: user-123',
|
||||
'Provider: MiMo\nType: OAuth',
|
||||
]) {
|
||||
const result = await resolveNativeCredentialState('mimo', {
|
||||
home,
|
||||
env: { PATH: '/usr/bin', MELDWORK_PRIVATE_VALUE: 'desktop-private-value' },
|
||||
executable: '/tmp/mimo',
|
||||
execFileFn: async (command, args, options) => {
|
||||
@@ -795,8 +798,11 @@ test('MiMo readiness uses the installed CLI identity without forwarding private
|
||||
assert.equal(calls.every(call => call.env.MELDWORK_PRIVATE_VALUE === undefined), true)
|
||||
})
|
||||
|
||||
test('MiMo readiness recognizes ANSI-colored logged-out output even when the CLI exits zero', async () => {
|
||||
test('MiMo readiness recognizes ANSI-colored logged-out output even when the CLI exits zero', async (t) => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-mimo-auth-'))
|
||||
t.after(() => fs.rmSync(home, { recursive: true, force: true }))
|
||||
const result = await resolveNativeCredentialState('mimo', {
|
||||
home,
|
||||
env: {},
|
||||
executable: '/tmp/mimo',
|
||||
execFileFn: async () => ({
|
||||
@@ -807,8 +813,10 @@ test('MiMo readiness recognizes ANSI-colored logged-out output even when the CLI
|
||||
assert.deepEqual(result, { state: 'missing', source: 'native-auth-status' })
|
||||
})
|
||||
|
||||
test('MiMo readiness remains unknown for missing or malformed identity output', async () => {
|
||||
assert.deepEqual(await resolveNativeCredentialState('mimo', {}), {
|
||||
test('MiMo readiness remains unknown for missing or malformed identity output', async (t) => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-mimo-auth-'))
|
||||
t.after(() => fs.rmSync(home, { recursive: true, force: true }))
|
||||
assert.deepEqual(await resolveNativeCredentialState('mimo', { home, env: {} }), {
|
||||
state: 'unknown', source: 'unverified',
|
||||
})
|
||||
|
||||
@@ -820,6 +828,7 @@ test('MiMo readiness remains unknown for missing or malformed identity output',
|
||||
'Authentication status unavailable',
|
||||
]) {
|
||||
const result = await resolveNativeCredentialState('mimo', {
|
||||
home,
|
||||
env: {},
|
||||
executable: '/tmp/mimo',
|
||||
execFileFn: async () => ({ stdout }),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"fixtures": [
|
||||
{ "kind": "zcode", "agent": "Zcode", "version": "0.16.1", "protocol": "zcode-prompt-json", "evidence": "source-derived", "file": "zcode.json" },
|
||||
{ "kind": "codex", "agent": "Codex", "version": "0.137.0", "protocol": "codex-app-server", "evidence": "captured-fixture", "file": "codex.jsonl" },
|
||||
{ "kind": "hermes", "agent": "Hermes", "version": "0.19.1", "protocol": "terminal-text", "evidence": "captured-fixture", "file": "hermes.txt" },
|
||||
{ "kind": "openclaw", "transport": "legacy", "agent": "OpenClaw", "version": "2026.7.1-2", "protocol": "openclaw-terminal-document", "evidence": "captured-fixture", "file": "openclaw.json" },
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"sessionId":"zcode-fixture-session","response":"Zcode fixture reply","projection":{"status":"idle","turnCount":1}}
|
||||
@@ -338,6 +338,30 @@ test('local preload exposes only the narrow Agent installer methods', async () =
|
||||
])
|
||||
})
|
||||
|
||||
test('WorkBuddy preload exposes only native model listing and ID selection', async () => {
|
||||
const { api, invocations } = loadPreload('file:')
|
||||
assert.equal(Object.isFrozen(api.workBuddy), true)
|
||||
assert.deepEqual(Object.keys(api.workBuddy).sort(), ['setModel', 'settings'])
|
||||
await api.workBuddy.settings()
|
||||
await api.workBuddy.setModel('text')
|
||||
assert.deepEqual(invocations, [
|
||||
{ channel: 'local-workbuddy:settings', args: [] },
|
||||
{ channel: 'local-workbuddy:set-model', args: ['text'] },
|
||||
])
|
||||
})
|
||||
|
||||
test('work preferences preload exposes only local get and save', async () => {
|
||||
const { api, invocations } = loadPreload('file:')
|
||||
assert.equal(Object.isFrozen(api.workPreferences), true)
|
||||
assert.deepEqual(Object.keys(api.workPreferences).sort(), ['get', 'save'])
|
||||
await api.workPreferences.get()
|
||||
await api.workPreferences.save({ detail: 'concise' })
|
||||
assert.deepEqual(invocations, [
|
||||
{ channel: 'local-work-preferences:get', args: [] },
|
||||
{ channel: 'local-work-preferences:save', args: [{ detail: 'concise' }] },
|
||||
])
|
||||
})
|
||||
|
||||
test('local preload exposes only review and revocation for Skill trust', async () => {
|
||||
const { api, invocations } = loadPreload('file:')
|
||||
const bindingId = `skill-trust-binding-${'a'.repeat(64)}`
|
||||
@@ -484,6 +508,8 @@ test('remote preload does not expose local credentials, workspace, or installer
|
||||
assert.equal(api.localOnly, true)
|
||||
assert.equal('getAuthToken' in api, false)
|
||||
assert.equal('localWorkspace' in api, false)
|
||||
assert.equal('workBuddy' in api, false)
|
||||
assert.equal('workPreferences' in api, false)
|
||||
assert.equal('cloudAgent' in api, false)
|
||||
assert.equal('localOutcome' in api, false)
|
||||
assert.equal('agentInstaller' in api, false)
|
||||
|
||||
@@ -15,6 +15,11 @@ function pngHeader(width = 1, height = 1) {
|
||||
}
|
||||
|
||||
const LOCAL_IPC_CHANNELS = Object.freeze([
|
||||
'local-delivery:save',
|
||||
'local-delivery:review',
|
||||
'local-delivery:revise',
|
||||
'local-delivery:reuse',
|
||||
'local-delivery:export',
|
||||
'local-workspace:get',
|
||||
'local-workspace:refresh-agents',
|
||||
'local-workspace:create-group',
|
||||
@@ -36,6 +41,10 @@ const LOCAL_IPC_CHANNELS = Object.freeze([
|
||||
'local-workspace:pick-directory',
|
||||
'local-workspace:default-directory',
|
||||
'local-agent-installer:catalog',
|
||||
'local-workbuddy:settings',
|
||||
'local-work-preferences:get',
|
||||
'local-work-preferences:save',
|
||||
'local-workbuddy:set-model',
|
||||
'local-agent-installer:skills',
|
||||
'local-skill-trust:list',
|
||||
'local-skill-trust:revoke',
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { LocalWorkspace } = require('../../src/workspace/local-workspace.cjs')
|
||||
const { attachmentIdsFromSnapshot } = require('../../src/attachments/attachment-service.cjs')
|
||||
const { registerDeliveryIpc } = require('../../src/shell/delivery-ipc.cjs')
|
||||
const { AttachmentStore } = require('../../src/attachments/attachment-store.cjs')
|
||||
const { captureAgentOutputState, importAgentOutputs } = require('../../src/agents/agent-output-importer.cjs')
|
||||
const { fixture, deferred } = require('../support/local-workspace-test-helpers.cjs')
|
||||
|
||||
const brief = { title: 'Monthly brief', goal: 'Explain progress', output: 'A sourced brief', outputKind: 'text', criteria: ['Sources checked', 'Gaps listed'], language: 'en' }
|
||||
async function setup(t, overrides = {}) {
|
||||
const f = fixture()
|
||||
t.after(() => fs.rmSync(f.directory, { recursive: true, force: true }))
|
||||
const workspace = new LocalWorkspace({ ...f.options, ...overrides })
|
||||
await workspace.refreshAgents()
|
||||
const group = workspace.createGroup({ name: 'Delivery', agentKinds: ['codex'], workdir: f.directory, allowWrite: false, conversationType: 'direct', directAgentKind: 'codex' })
|
||||
const taskId = workspace.delivery.save({ groupId: group.id, brief })
|
||||
const send = (extra = {}) => workspace.sendMessage({ groupId: group.id, deliveryTaskId: taskId, targetKinds: ['codex'], text: 'Current month only', mode: 'manual', ...extra })
|
||||
return { ...f, workspace, group, taskId, send }
|
||||
}
|
||||
|
||||
test('task runs freeze inputs, create independent versions, require human acceptance and survive restart', async t => {
|
||||
const f = await setup(t)
|
||||
await f.send()
|
||||
let task = f.workspace.delivery.task(f.taskId)
|
||||
assert.equal(task.attempts[0].status, 'completed')
|
||||
assert.equal(task.versions.length, 1)
|
||||
assert.match(f.calls[0].prompt, /Explain progress/)
|
||||
assert.doesNotMatch(f.workspace.snapshot().messages.find(item => item.id === task.attempts[0].messageId).content, /Human acceptance is separate/)
|
||||
assert.equal(task.versions[0].acceptedAt, '')
|
||||
assert.throws(() => f.workspace.delivery.save({ groupId: f.group.id, taskId: f.taskId, brief }), /LOCKED/)
|
||||
const v1 = task.versions[0].id
|
||||
assert.throws(() => f.workspace.delivery.review({ taskId: f.taskId, versionId: v1, checks: ['met', 'unknown'], notes: '', accept: true }), /NOT_READY/)
|
||||
f.workspace.delivery.review({ taskId: f.taskId, versionId: v1, checks: ['met', 'met'], notes: 'Checked source data', accept: true })
|
||||
const acceptedAt = f.workspace.delivery.version(f.workspace.delivery.task(f.taskId), v1).acceptedAt
|
||||
f.workspace.delivery.revise({ taskId: f.taskId, baseVersionId: v1, note: 'Shorten the summary' })
|
||||
await f.send({ text: 'Use the new period' })
|
||||
task = f.workspace.delivery.task(f.taskId)
|
||||
assert.equal(task.versions.length, 2)
|
||||
assert.equal(task.versions[0].acceptedAt, acceptedAt)
|
||||
assert.deepEqual(task.versions[1].checks, ['pending', 'pending'])
|
||||
assert.equal(task.attempts[1].revision.baseVersionId, v1)
|
||||
assert.match(f.calls[1].prompt, /Shorten the summary/)
|
||||
assert.match(f.workspace.delivery.markdown(f.taskId, v1), /Checked source data/)
|
||||
const reopened = new LocalWorkspace(f.options)
|
||||
assert.equal(reopened.delivery.task(f.taskId).versions.length, 2)
|
||||
assert.equal(reopened.delivery.task(f.taskId).versions[0].acceptedAt, acceptedAt)
|
||||
})
|
||||
|
||||
test('ordinary chat and other tasks in the same conversation cannot change a frozen version', async t => {
|
||||
const f = await setup(t)
|
||||
await f.send()
|
||||
const original = structuredClone(f.workspace.delivery.task(f.taskId).versions)
|
||||
await f.workspace.sendMessage({ groupId: f.group.id, targetKinds: ['codex'], text: 'Unrelated chat', mode: 'manual' })
|
||||
const secondId = f.workspace.delivery.save({ groupId: f.group.id, brief: { ...brief, title: 'Different task' } })
|
||||
await f.send({ deliveryTaskId: secondId, text: 'Different input' })
|
||||
assert.deepEqual(f.workspace.delivery.task(f.taskId).versions, original)
|
||||
assert.equal(f.workspace.delivery.task(secondId).versions.length, 1)
|
||||
assert.notEqual(f.workspace.delivery.task(secondId).versions[0].runId, original[0].runId)
|
||||
})
|
||||
|
||||
test('manual V4 committed proposal messages are deliverables, uncommitted discussion is not', async t => {
|
||||
const f = await setup(t)
|
||||
const task = f.workspace.delivery.task(f.taskId)
|
||||
f.workspace.delivery.commit(() => task.attempts.push({ runId: 'v4-run', messageId: 'root', status: 'running', createdAt: f.workspace.now(), input: '', sources: [], revision: null }))
|
||||
f.workspace.state.messages.push(
|
||||
{ id: 'root', role: 'user', groupId: f.group.id, content: 'Task' },
|
||||
{ id: 'proposal', role: 'agent', groupId: f.group.id, agentKind: 'codex', content: 'Committed answer', threadRootId: 'root', trace: { runId: 'v4-run', phase: 'proposal' } },
|
||||
{ id: 'discussion', role: 'agent', groupId: f.group.id, agentKind: 'codex', content: 'Internal discussion', threadRootId: 'root', trace: { runId: 'v4-run', phase: 'discussion' } },
|
||||
)
|
||||
f.workspace.delivery.complete('v4-run', 'completed', ['proposal'])
|
||||
assert.deepEqual(task.versions[0].responses.map(item => item.content), ['Committed answer'])
|
||||
f.workspace.delivery.complete('v4-run', 'completed', ['proposal'])
|
||||
assert.equal(task.versions.length, 1)
|
||||
})
|
||||
|
||||
test('rejects invalid fields, cross-conversation binding and edits during a run', async t => {
|
||||
const pending = deferred()
|
||||
const f = await setup(t, { runAgent: async () => pending.promise })
|
||||
assert.throws(() => f.workspace.delivery.save({ groupId: f.group.id, brief: { ...brief, criteria: [] } }), /INVALID/)
|
||||
const other = f.workspace.createGroup({ name: 'Other', agentKinds: ['codex'], workdir: f.directory })
|
||||
await assert.rejects(f.send({ groupId: other.id }), /INVALID/)
|
||||
const running = f.send()
|
||||
for (let i = 0; i < 100 && !f.workspace.delivery.task(f.taskId).attempts.length; i++) await new Promise(resolve => setTimeout(resolve, 5))
|
||||
assert.throws(() => f.workspace.delivery.save({ groupId: f.group.id, taskId: f.taskId, brief }), /RUNNING/)
|
||||
pending.resolve({ text: 'Actual result', outcome: 'completed' })
|
||||
await running
|
||||
assert.equal(f.workspace.delivery.task(f.taskId).versions.length, 1)
|
||||
})
|
||||
|
||||
test('failed runs do not turn system errors into deliverables; file tasks require actual attachments', async t => {
|
||||
const f = await setup(t, { runAgent: async () => { throw new Error('offline') } })
|
||||
await f.send()
|
||||
assert.equal(f.workspace.delivery.task(f.taskId).versions.length, 0)
|
||||
assert.equal(f.workspace.delivery.task(f.taskId).attempts[0].status, 'failed')
|
||||
const good = await setup(t)
|
||||
good.workspace.delivery.save({ groupId: good.group.id, taskId: good.taskId, brief: { ...brief, outputKind: 'files' } })
|
||||
await good.send()
|
||||
const task = good.workspace.delivery.task(good.taskId)
|
||||
assert.throws(() => good.workspace.delivery.review({ taskId: task.id, versionId: task.versions[0].id, checks: ['met', 'met'], notes: '', accept: true }), /NOT_READY/)
|
||||
})
|
||||
|
||||
test('version and attachment references survive message deletion; deleting the conversation removes tasks', async t => {
|
||||
const attachment = { id: 'file-output', name: 'result.csv', mimeType: 'text/csv', size: 10 }
|
||||
const f = await setup(t)
|
||||
await f.send()
|
||||
const task = f.workspace.delivery.task(f.taskId)
|
||||
// Exercise retention independently from the Agent importer protocol.
|
||||
f.workspace.delivery.commit(() => { task.versions[0].attachments = [attachment] })
|
||||
const messageId = task.versions[0].responses[0].messageId
|
||||
f.workspace.deleteMessage(f.group.id, messageId)
|
||||
assert.equal(f.workspace.delivery.task(f.taskId).versions.length, 1)
|
||||
assert.ok(attachmentIdsFromSnapshot(f.workspace.snapshot()).includes(attachment.id))
|
||||
f.workspace.deleteGroup(f.group.id)
|
||||
assert.equal(f.workspace.state.deliveryTasks.length, 0)
|
||||
})
|
||||
|
||||
test('persistence failure rolls back task mutations and restart marks orphaned work interrupted', async t => {
|
||||
const f = await setup(t)
|
||||
const save = f.workspace.save.bind(f.workspace)
|
||||
f.workspace.save = () => { throw new Error('disk full') }
|
||||
assert.throws(() => f.workspace.delivery.save({ groupId: f.group.id, taskId: f.taskId, brief: { ...brief, title: 'Unsaved' } }), /disk full/)
|
||||
assert.equal(f.workspace.delivery.task(f.taskId).brief.title, brief.title)
|
||||
f.workspace.save = save
|
||||
f.workspace.delivery.commit(() => f.workspace.delivery.task(f.taskId).attempts.push({ runId: 'orphan', messageId: 'orphan-message', status: 'running', createdAt: f.workspace.now(), input: '', sources: [], revision: null }))
|
||||
const reopened = new LocalWorkspace(f.options)
|
||||
assert.equal(reopened.delivery.task(f.taskId).attempts[0].status, 'interrupted')
|
||||
})
|
||||
|
||||
test('trusted export validates version ownership, handles cancellation and preserves acceptance; reuse clears context and writes', async t => {
|
||||
const f = await setup(t)
|
||||
await f.send()
|
||||
const handlers = new Map()
|
||||
let saveResult = { canceled: true }
|
||||
registerDeliveryIpc({
|
||||
registerTrustedHandle: (name, callback) => handlers.set(name, callback), getWorkspace: () => f.workspace,
|
||||
getAttachmentStore: () => null, getMainWindow: () => null,
|
||||
dialog: { showSaveDialog: async () => saveResult },
|
||||
})
|
||||
const version = f.workspace.delivery.task(f.taskId).versions[0]
|
||||
const payload = { taskId: f.taskId, versionId: version.id }
|
||||
const exportVersion = handlers.get('local-delivery:export')
|
||||
assert.deepEqual(await exportVersion(payload), { saved: false })
|
||||
await assert.rejects(exportVersion({ ...payload, filePath: '/tmp/unsafe' }), /INVALID/)
|
||||
await assert.rejects(exportVersion({ ...payload, attachmentId: 'another-file' }), /NOT_FOUND/)
|
||||
saveResult = { canceled: false, filePath: path.join(f.directory, 'export.md') }
|
||||
assert.deepEqual(await exportVersion(payload), { saved: true })
|
||||
assert.match(fs.readFileSync(saveResult.filePath, 'utf8'), /Input materials/)
|
||||
assert.equal(version.acceptedAt, '')
|
||||
saveResult.filePath = path.join(f.directory, 'missing', 'export.md')
|
||||
await assert.rejects(exportVersion(payload), /EXPORT_FAILED/)
|
||||
const reused = handlers.get('local-delivery:reuse')({ taskId: f.taskId })
|
||||
const newGroup = f.workspace.getGroup(reused.groupId)
|
||||
const newTask = f.workspace.delivery.task(reused.taskId)
|
||||
assert.equal(newGroup.allowWrite, false)
|
||||
assert.notEqual(newGroup.workdir, f.group.workdir)
|
||||
assert.deepEqual(fs.readdirSync(newGroup.workdir), [])
|
||||
assert.deepEqual(newTask.attempts, [])
|
||||
assert.deepEqual(newTask.versions, [])
|
||||
assert.equal(f.workspace.state.messages.some(item => item.groupId === reused.groupId), false)
|
||||
})
|
||||
|
||||
test('actual generated files are imported, exported and carried into a revision with fresh source references', async t => {
|
||||
let store
|
||||
let fileNumber = 0
|
||||
const f = await setup(t, {
|
||||
attachmentSupport: () => ({ image: 4, file: 8, audio: 1, video: 1 }),
|
||||
resolveAttachments: refs => store.resolve(refs),
|
||||
captureAgentOutputs: captureAgentOutputState,
|
||||
importAgentOutputs: input => importAgentOutputs(input, store),
|
||||
runAgent: async (_agent, _prompt, workdir) => {
|
||||
const output = path.join(workdir, '.meldwork-output')
|
||||
fs.mkdirSync(output, { recursive: true })
|
||||
const filename = path.join(output, `brief-${++fileNumber}.csv`)
|
||||
fs.writeFileSync(filename, 'project,progress\nAlpha,75\n')
|
||||
return { text: `Generated [brief](${filename})`, outcome: 'completed' }
|
||||
},
|
||||
})
|
||||
store = new AttachmentStore({ rootPath: path.join(f.directory, 'attachments') })
|
||||
f.workspace.updateGroup(f.group.id, { allowWrite: true })
|
||||
f.workspace.delivery.save({ groupId: f.group.id, taskId: f.taskId, brief: { ...brief, outputKind: 'files' } })
|
||||
await f.send()
|
||||
const task = f.workspace.delivery.task(f.taskId)
|
||||
const version = task.versions[0]
|
||||
assert.equal(version.attachments.length, 1)
|
||||
const file = version.attachments[0]
|
||||
const handlers = new Map()
|
||||
const destination = path.join(f.directory, 'export.csv')
|
||||
registerDeliveryIpc({ registerTrustedHandle: (name, handler) => handlers.set(name, handler), getWorkspace: () => f.workspace, getAttachmentStore: () => store, getMainWindow: () => null, dialog: { showSaveDialog: async () => ({ canceled: false, filePath: destination }) } })
|
||||
await handlers.get('local-delivery:export')({ taskId: task.id, versionId: version.id, attachmentId: file.id })
|
||||
assert.equal(fs.readFileSync(destination, 'utf8'), 'project,progress\nAlpha,75\n')
|
||||
handlers.get('local-delivery:review')({ taskId: task.id, versionId: version.id, checks: ['met', 'met'], notes: '', accept: true })
|
||||
f.workspace.delivery.revise({ taskId: task.id, baseVersionId: version.id, note: 'Update the CSV' })
|
||||
await f.send()
|
||||
assert.ok(task.attempts[1].sources.some(item => item.attachmentId === file.id))
|
||||
assert.equal(task.versions.length, 2, JSON.stringify(f.workspace.snapshot().messages.at(-1)))
|
||||
assert.equal(task.versions[1].attachments.length, 1)
|
||||
assert.equal(task.versions[1].acceptedAt, '')
|
||||
})
|
||||
@@ -45,6 +45,25 @@ test('Agent catalog module exposes only its coordinator', () => {
|
||||
assert.deepEqual(Object.keys(catalogApi), ['LocalWorkspaceAgentCatalog'])
|
||||
})
|
||||
|
||||
test('WorkBuddy encrypted desktop login blocks standalone invocation with a specific configuration reason', async () => {
|
||||
const { catalog, agents, state } = fixture({
|
||||
detectAgents: async () => [{ kind: 'workbuddy', version: '2.132.0', compatibilityState: 'compatible' }],
|
||||
credentialState: async () => ({ state: 'unknown', source: 'workbuddy-desktop-locked' }),
|
||||
})
|
||||
state.messages.push({ role: 'agent', agentKind: 'workbuddy' })
|
||||
await catalog.refresh()
|
||||
assert.equal(agents()[0].available, false)
|
||||
assert.equal(agents()[0].availabilitySource, 'workbuddy-desktop-locked')
|
||||
})
|
||||
|
||||
test('sidebar visibility preserves a selected WorkBuddy model', async () => {
|
||||
const { catalog, state } = fixture({ detectAgents: async () => [{ kind: 'workbuddy' }] })
|
||||
state.agentPreferences.workbuddy = { modelId: 'native-text' }
|
||||
await catalog.refresh()
|
||||
catalog.setSidebarVisibility('workbuddy', false)
|
||||
assert.deepEqual(state.agentPreferences.workbuddy, { modelId: 'native-text', showInSidebar: false })
|
||||
})
|
||||
|
||||
test('one credential probe failure retains that installation and does not hide healthy peers', async () => {
|
||||
const { catalog } = fixture({
|
||||
detectAgents: async () => ['codex', 'claude'].map(kind => ({ kind, compatibilityState: 'compatible' })),
|
||||
|
||||
@@ -80,7 +80,7 @@ test('Natural uncertain delivery is resent, and partial or omitted entries are n
|
||||
assert.match(f.prepare().prompt, /RETRY_EVIDENCE/)
|
||||
f.add(`LONG_START\n${'x'.repeat(100000)}\nLONG_END`)
|
||||
const partial = f.prepare()
|
||||
assert.match(partial.prompt, /Middle of this turn omitted/)
|
||||
assert.match(partial.prompt, /Excerpt: omitted text/)
|
||||
assert.equal(partial.delivery.entries[0].sourceMessages.length, 1)
|
||||
f.settle(partial)
|
||||
const repeated = f.prepare()
|
||||
|
||||
@@ -33,7 +33,7 @@ test('Natural handoff tells every participant to return control to the outer sch
|
||||
|
||||
test('Natural transcript retains complete short contributions in chronological order', () => {
|
||||
const entries = ['one', 'two', 'three'].map((kind, index) => entry(
|
||||
index, kind, `${kind}-opening\n${'x'.repeat(9000)}\n${kind}-closing`,
|
||||
index, kind, `${kind}-opening\n${'x'.repeat(900)}\n${kind}-closing`,
|
||||
))
|
||||
const text = packNaturalDiscussionTranscript(entries)
|
||||
assert.doesNotMatch(text, /partial|omitted/u)
|
||||
@@ -49,12 +49,11 @@ test('Natural transcript stays bounded and prioritizes the latest contribution o
|
||||
}
|
||||
entries.push(entry(45, 'active-peer', 'NEWEST_HANDOFF: @quiet-peer verify the original evidence.'))
|
||||
const text = packNaturalDiscussionTranscript(entries)
|
||||
assert.ok(text.length <= 48000)
|
||||
assert.ok(text.length <= 16000)
|
||||
assert.match(text, /This transcript is partial/u)
|
||||
assert.match(text, /Omission is not evidence of agreement or completion/u)
|
||||
assert.match(text, /QUIET_EVIDENCE/u)
|
||||
assert.match(text, /NEWEST_HANDOFF/u)
|
||||
assert.doesNotMatch(text, /OLDER_1\n/u)
|
||||
assert.ok(text.indexOf('QUIET_EVIDENCE') < text.indexOf('NEWEST_HANDOFF'))
|
||||
assert.ok(entries[1].text.includes('OLDER_1'))
|
||||
})
|
||||
@@ -62,9 +61,9 @@ test('Natural transcript stays bounded and prioritizes the latest contribution o
|
||||
test('Oversized contributions preserve closing handoffs and mark the omitted middle', () => {
|
||||
const original = `OPENING_FINDING\n${'x'.repeat(100000)}\n@peer CLOSE_WITH_REQUEST`
|
||||
const text = packNaturalDiscussionTranscript([entry(0, 'author', original)])
|
||||
assert.ok(text.length <= 48000)
|
||||
assert.ok(text.length <= 16000)
|
||||
assert.match(text, /OPENING_FINDING/u)
|
||||
assert.match(text, /Middle of this turn omitted/u)
|
||||
assert.match(text, /Excerpt: omitted text/u)
|
||||
assert.match(text, /@peer CLOSE_WITH_REQUEST/u)
|
||||
assert.match(text, /Ask peers to restate any missing evidence/u)
|
||||
})
|
||||
@@ -72,7 +71,7 @@ test('Oversized contributions preserve closing handoffs and mark the omitted mid
|
||||
test('Natural transcript cannot grow without bound through many tiny turns', () => {
|
||||
const entries = Array.from({ length: 1200 }, (_, index) => entry(index, 'peer', `TURN_${index}`))
|
||||
const text = packNaturalDiscussionTranscript(entries)
|
||||
assert.ok(text.length <= 48000)
|
||||
assert.ok(text.length <= 16000)
|
||||
assert.match(text, /This transcript is partial/u)
|
||||
assert.match(text, /TURN_1199/u)
|
||||
assert.doesNotMatch(text, /TURN_0\n/u)
|
||||
|
||||
@@ -38,7 +38,7 @@ function terminalRun(ledger, runId) {
|
||||
return ['completed', 'partial', 'failed', 'stopped', 'round-limit'].includes(record?.status)
|
||||
? record
|
||||
: null
|
||||
}, `terminal Run ${runId}`, 10000)
|
||||
}, `terminal Run ${runId}`, 30000)
|
||||
}
|
||||
|
||||
function proposalReceipt(agentKind) {
|
||||
|
||||
@@ -12,6 +12,33 @@ function delay(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
test('stopping unlimited discussion persists partial proposal output before removing the live run', { timeout: 10000 }, async t => {
|
||||
const { directory, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const entered = deferred()
|
||||
options.naturalAgentResponses = true
|
||||
options.runAgent = async (_agent, _prompt, _workdir, runOptions) => {
|
||||
runOptions.onEvent({ type: 'answer_delta', status: 'running', delta: '已输出的研究结果。' })
|
||||
entered.resolve()
|
||||
await new Promise((resolve, reject) => runOptions.signal.addEventListener('abort',
|
||||
() => reject(new Error('LOCAL_AGENT_EXECUTION_STOPPED')), { once: true }))
|
||||
}
|
||||
const workspace = new LocalWorkspace(options)
|
||||
await workspace.refreshAgents()
|
||||
const group = workspace.createGroup({ name: 'Partial proposal', agentKinds: ['codex', 'hermes'], workdir: directory })
|
||||
const sending = workspace.sendMessage({ groupId: group.id, text: 'Research', mode: 'auto', protocol: 'v4',
|
||||
targetKinds: group.agentKinds, discussionStyle: 'agent-led', unlimitedRounds: true })
|
||||
await entered.promise
|
||||
const controller = workspace.activeRuns.get(group.id)
|
||||
workspace.stop(group.id, controller.runId)
|
||||
await sending
|
||||
await controller.promise
|
||||
const messages = workspace.snapshot().messages.filter(m => m.system?.key === 'system.agentStopped')
|
||||
assert.ok(messages.some(m => m.content.includes('已输出的研究结果。')), JSON.stringify(workspace.snapshot().messages))
|
||||
const reopened = new LocalWorkspace(options)
|
||||
assert.ok(reopened.snapshot().messages.some(m => m.content.includes('已输出的研究结果。')))
|
||||
})
|
||||
|
||||
function decisionReply(text, status = 'completed', extra = {}) {
|
||||
return `${text}\n\n[[MELDWORK_COLLABORATION:${JSON.stringify({
|
||||
summary: 'Task progress judged by the delivery owner.',
|
||||
@@ -23,6 +50,36 @@ function decisionReply(text, status = 'completed', extra = {}) {
|
||||
})}]]`
|
||||
}
|
||||
|
||||
test('a native repetition failure preserves the partial proposal through reload', { timeout: 10000 }, async t => {
|
||||
const { directory, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const entered = deferred()
|
||||
const release = deferred()
|
||||
options.naturalAgentResponses = true
|
||||
options.runAgent = async (agent, _prompt, _workdir, runOptions) => {
|
||||
if (agent.kind === 'codex') return { text: 'Independent analysis completed.', outcome: 'completed' }
|
||||
runOptions.onEvent({ type: 'answer_delta', status: 'running', delta: '已完成的初步研究结果。' })
|
||||
entered.resolve()
|
||||
await release.promise
|
||||
throw new Error('LOCAL_AGENT_REPEATED_OUTPUT')
|
||||
}
|
||||
const workspace = new LocalWorkspace(options)
|
||||
await workspace.refreshAgents()
|
||||
const group = workspace.createGroup({ name: 'Repetition failure', agentKinds: ['codex', 'hermes'], workdir: directory })
|
||||
const sending = workspace.sendMessage({ groupId: group.id, text: 'Research', mode: 'auto', protocol: 'v4',
|
||||
targetKinds: group.agentKinds, discussionStyle: 'agent-led', maxRounds: 1 })
|
||||
await entered.promise
|
||||
const controller = workspace.activeRuns.get(group.id)
|
||||
release.resolve()
|
||||
await sending
|
||||
await controller.promise
|
||||
const reopened = new LocalWorkspace(options)
|
||||
assert.ok(reopened.snapshot().messages.some(message => (
|
||||
message.system?.params?.reason === 'LOCAL_AGENT_REPEATED_OUTPUT'
|
||||
&& message.content.includes('已完成的初步研究结果。')
|
||||
)))
|
||||
})
|
||||
|
||||
function naturalPhase(prompt) {
|
||||
if (prompt.includes('Work independently on the user task')) return 'proposal'
|
||||
if (prompt.includes('Continue from the available peer responses')) return 'discussion'
|
||||
@@ -464,7 +521,7 @@ for (const discussionStyle of ['sequential', 'agent-led']) {
|
||||
})
|
||||
}
|
||||
|
||||
test('Natural Agent-led V4 uses the full first-round transcript and inline mentions', async (t) => {
|
||||
test('Natural Agent-led V4 compacts first-round background while preserving all peer handoffs and inline mentions', async (t) => {
|
||||
const { directory, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const ledger = new RunLedger({ storagePath: path.join(directory, 'run-ledger.json') })
|
||||
@@ -534,7 +591,9 @@ test('Natural Agent-led V4 uses the full first-round transcript and inline menti
|
||||
assert.equal(maxRoutedRunning, 3)
|
||||
const coordinator = calls[3]
|
||||
for (const sentinel of tails.values()) assert.match(coordinator.prompt, new RegExp(sentinel))
|
||||
assert.ok(coordinator.prompt.length > 24000)
|
||||
assert.ok(coordinator.prompt.length < 24000)
|
||||
assert.match(coordinator.prompt, /Excerpt: omitted text/u)
|
||||
assert.equal(coordinator.prompt.includes('x'.repeat(9000)), false)
|
||||
assert.equal(coordinator.sessionRef, 'codex-task-session')
|
||||
assert.equal(calls[4].sessionRef, 'hermes-task-session')
|
||||
assert.equal(calls[5].sessionRef, 'workbuddy-task-session')
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const { sharedContextExcerpt } = require('../../src/workspace/shared-context-excerpt.cjs')
|
||||
const { packNaturalDiscussionTranscript, v4Snapshot, v4Prompt } = require('../../src/workspace/local-workspace-context.cjs')
|
||||
|
||||
test('extractive context retains middle constraints, decisions, evidence and unresolved contradictions', () => {
|
||||
const facts = ['Constraint: budget must not exceed 500.', 'Decision: use option B, not option A.', 'Evidence: https://example.org/report section 7.', 'Unresolved conflict: supplier cannot meet 2026-11-01.']
|
||||
const text = ['Opening context.', ...Array(50).fill('Background detail without a decision.'), ...facts, ...Array(50).fill('Repeated generic discussion.'), 'Closing handoff: verify the conflict.'].join('\n')
|
||||
const result = sharedContextExcerpt(text, 1300)
|
||||
assert.equal(result.excerpted, true)
|
||||
assert.ok(result.text.length <= 1300)
|
||||
for (const fact of facts) assert.ok(result.text.includes(fact), fact)
|
||||
assert.match(result.text, /do not infer agreement/)
|
||||
})
|
||||
|
||||
test('compaction fairly represents all 32 peers, stays within budget and never acknowledges excerpts as full messages', () => {
|
||||
const entries = Array.from({ length: 32 }, (_, index) => ({ id: `message-${index}`, agentKind: `peer-${index}`, round: 1,
|
||||
text: `Evidence peer-${index}: source-${index}.\n${'Repeated background.\n'.repeat(500)}\nUnresolved peer-${index}: verify result.` }))
|
||||
const packed = packNaturalDiscussionTranscript(entries, { withEntries: true })
|
||||
assert.ok(packed.text.length <= 16000)
|
||||
for (let index = 0; index < 32; index++) {
|
||||
assert.ok(packed.text.includes(`@peer-${index} [`))
|
||||
assert.ok(packed.text.includes(`Unresolved peer-${index}`))
|
||||
}
|
||||
assert.deepEqual(packed.completeMessageIds, [])
|
||||
assert.equal(packed.excerptedMessageIds.length, 32)
|
||||
assert.ok(packed.deliveredChars / packed.sourceChars < 0.1)
|
||||
})
|
||||
|
||||
test('all peers receive one frozen task and history identity, including early user constraints', () => {
|
||||
const group = { id: 'group', name: 'Shared', topic: 'Review' }
|
||||
const messages = Array.from({ length: 30 }, (_, index) => ({ id: `m-${index}`, groupId: group.id, role: index % 3 ? 'agent' : 'user', content: index === 0 ? 'Never publish private data. Budget limit: 500.' : `Historical turn ${index}.` }))
|
||||
const message = { id: 'current', groupId: group.id, role: 'user', content: 'Compare the proposals and list unresolved disagreements.' }
|
||||
const kinds = ['codex', 'hermes', 'workbuddy']
|
||||
const snapshot = v4Snapshot({ state: { messages: [...messages, message] }, group, taskId: message.id, targetKinds: kinds, message, skillHintsByKind: new Map() })
|
||||
assert.ok(snapshot.history.some(item => item.id === 'm-0'))
|
||||
assert.ok(snapshot.history.length <= 16)
|
||||
const original = JSON.stringify(snapshot)
|
||||
for (const kind of kinds) {
|
||||
const prompt = v4Prompt({ group, kind, snapshot, naturalResponse: true, phase: 'proposal' })
|
||||
assert.ok(prompt.includes(message.content))
|
||||
assert.match(prompt, /Never publish private data/)
|
||||
assert.match(prompt, /Budget limit: 500/)
|
||||
assert.equal(JSON.stringify(snapshot), original)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const { parseWorkPreferences, loadedWorkPreferences, workPreferencesPrompt } = require('../../src/workspace/work-preferences.cjs')
|
||||
const { LocalWorkspace } = require('../../src/workspace/local-workspace.cjs')
|
||||
const { registerDesktopIpc } = require('../../src/shell/main-ipc.cjs')
|
||||
const { fixture } = require('../support/local-workspace-test-helpers.cjs')
|
||||
|
||||
const preference = { detail: 37, initiative: 82, evidence: 91, preferredAgentKinds: ['codex'], instructions: 'Use a comparison table.' }
|
||||
|
||||
test('legacy preferences migrate while continuous scores retain their exact values', () => {
|
||||
assert.deepEqual(parseWorkPreferences({ ...preference, detail: 'concise', initiative: 'balanced', evidence: 'strict' }), { ...preference, detail: 0, initiative: 50, evidence: 100 })
|
||||
assert.deepEqual(loadedWorkPreferences(preference), preference)
|
||||
for (const detail of [-1, 101, 33.5, NaN, '37']) assert.throws(() => parseWorkPreferences({ ...preference, detail }), /INVALID/)
|
||||
assert.match(workPreferencesPrompt(preference), /Delivery detail: 37\/100/)
|
||||
assert.match(workPreferencesPrompt(preference), /Initiative: 82\/100/)
|
||||
assert.match(workPreferencesPrompt(preference), /Evidence standard: 91\/100/)
|
||||
})
|
||||
|
||||
test('delivery preferences stay short, reject unknown fields, and cannot change permissions', () => {
|
||||
assert.equal(workPreferencesPrompt(undefined), '')
|
||||
assert.equal(workPreferencesPrompt(loadedWorkPreferences(null)), '')
|
||||
assert.throws(() => parseWorkPreferences({ ...preference, allowWrite: true }), /INVALID/)
|
||||
assert.throws(() => parseWorkPreferences({ ...preference, instructions: 'x'.repeat(801) }), /INVALID/)
|
||||
assert.throws(() => parseWorkPreferences({ ...preference, preferredAgentKinds: Array(9).fill('codex') }), /INVALID/)
|
||||
const text = workPreferencesPrompt({ ...preference, instructions: 'x'.repeat(800) })
|
||||
assert.ok(text.length < 1500)
|
||||
assert.match(text, /permission boundaries take precedence/)
|
||||
assert.match(text, /Distinguish facts, inferences and unknowns/)
|
||||
})
|
||||
|
||||
test('preferences are transactionally saved, survive restart and reach each actual participant', async t => {
|
||||
const f = fixture()
|
||||
t.after(() => fs.rmSync(f.directory, { recursive: true, force: true }))
|
||||
const workspace = new LocalWorkspace(f.options)
|
||||
const handles = new Map()
|
||||
registerDesktopIpc({ registerTrustedHandle: (name, handler) => handles.set(name, handler), getWorkspace: () => workspace })
|
||||
const save = handles.get('local-work-preferences:save')
|
||||
assert.throws(() => save({ ...preference, detail: 'unbounded' }), /INVALID/)
|
||||
workspace.activeRuns.set('busy', {})
|
||||
assert.throws(() => save(preference), /BUSY/)
|
||||
workspace.activeRuns.clear()
|
||||
save(preference)
|
||||
assert.deepEqual(handles.get('local-work-preferences:get')(), preference)
|
||||
const restarted = new LocalWorkspace(f.options)
|
||||
assert.deepEqual(restarted.state.workPreferences, preference)
|
||||
await restarted.refreshAgents()
|
||||
const group = restarted.createGroup({ name: 'Shared task', agentKinds: ['codex', 'hermes', 'workbuddy'], workdir: f.directory, allowWrite: false })
|
||||
await restarted.sendMessage({ groupId: group.id, text: 'Use the supplied evidence. Budget must not exceed 500.', targetKinds: group.agentKinds, mode: 'manual' })
|
||||
assert.equal(f.calls.length, 3)
|
||||
for (const call of f.calls) {
|
||||
assert.match(call.prompt, /Use a comparison table/)
|
||||
assert.match(call.prompt, /Budget must not exceed 500/)
|
||||
assert.match(call.prompt, /Delivery detail: 37\/100/)
|
||||
assert.match(call.prompt, /Initiative: 82\/100/)
|
||||
assert.match(call.prompt, /Evidence standard: 91\/100/)
|
||||
assert.equal(call.runOptions.sandbox, 'read-only')
|
||||
}
|
||||
const before = workspace.state.workPreferences
|
||||
workspace.save = () => { throw new Error('disk-full') }
|
||||
assert.throws(() => save({ ...preference, detail: 'thorough' }), /disk-full/)
|
||||
assert.deepEqual(workspace.state.workPreferences, before)
|
||||
})
|
||||
Generated
+9
-2
@@ -1,17 +1,18 @@
|
||||
{
|
||||
"name": "meldwork-frontend",
|
||||
"version": "0.1.6",
|
||||
"version": "1.0.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meldwork-frontend",
|
||||
"version": "0.1.6",
|
||||
"version": "1.0.8",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@vicons/ionicons5": "^0.12.0",
|
||||
"dompurify": "^3.4.13",
|
||||
"marked": "^9.0.0",
|
||||
"three": "^0.185.1",
|
||||
"vue": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -2240,6 +2241,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/three": {
|
||||
"version": "0.185.1",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz",
|
||||
"integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "meldwork-frontend",
|
||||
"version": "0.1.6",
|
||||
"version": "1.0.8",
|
||||
"private": true,
|
||||
"description": "Meldwork local-first multi-Agent collaboration workspace",
|
||||
"author": "Ryder-MHumble",
|
||||
@@ -20,6 +20,7 @@
|
||||
"@vicons/ionicons5": "^0.12.0",
|
||||
"dompurify": "^3.4.13",
|
||||
"marked": "^9.0.0",
|
||||
"three": "^0.185.1",
|
||||
"vue": "^3.4.0"
|
||||
},
|
||||
"overrides": {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.3 KiB |
+32
-5
@@ -12,7 +12,7 @@
|
||||
<section v-if="booting" class="boot-state" aria-live="polite">
|
||||
<img class="boot-logo" :src="productAppIcon" alt="Meldwork" />
|
||||
<p>{{ t('app.loading') }}</p>
|
||||
<span class="boot-progress" aria-hidden="true"><span /></span>
|
||||
<WavePhysicsLoader :theme="theme" />
|
||||
</section>
|
||||
|
||||
<section v-else-if="bridgeMissing" class="bridge-state">
|
||||
@@ -26,6 +26,33 @@
|
||||
:t="t" :theme="theme" :active-view="activeView" :blocked="contentInteractionBlocked"
|
||||
:open-system-settings="openSystemSettings" :toggle-locale="toggleLocale" :toggle-theme="toggleTheme"
|
||||
:shortcut-definitions="shortcutDefinitions" />
|
||||
<transition name="agent-discovery" appear>
|
||||
<section
|
||||
v-if="agentDiscoveryPending"
|
||||
class="agent-discovery-overlay"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
:aria-label="t('agentDiscovery.title')"
|
||||
>
|
||||
<PixelBlast :theme="theme" />
|
||||
<div class="agent-discovery-content">
|
||||
<img class="agent-discovery-mark" :src="productMark" alt="" />
|
||||
<div class="agent-discovery-copy">
|
||||
<p class="agent-discovery-kicker">{{ t('agentDiscovery.kicker') }}</p>
|
||||
<h1>{{ t('agentDiscovery.title') }}</h1>
|
||||
<p>{{ t('agentDiscovery.body') }}</p>
|
||||
<div class="agent-discovery-progress" aria-hidden="true">
|
||||
<span />
|
||||
</div>
|
||||
<div class="agent-discovery-status">
|
||||
<span class="agent-discovery-status-dot" aria-hidden="true" />
|
||||
<span>{{ t('agentDiscovery.status') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</transition>
|
||||
|
||||
<WorkspaceSidebar :controller="workspaceSidebarController" />
|
||||
|
||||
<section
|
||||
@@ -169,6 +196,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import PixelBlast from './components/PixelBlast.vue'
|
||||
import WavePhysicsLoader from './components/WavePhysicsLoader.vue'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import {
|
||||
AlertCircleOutline,
|
||||
@@ -229,7 +258,7 @@ const shortcutDefinitions = Object.freeze([
|
||||
{ labelKey: 'shortcut.openAgents', keys: '⌘ ,' },
|
||||
])
|
||||
const READ_ONLY_ENFORCED_AGENT_KINDS = new Set([
|
||||
'codex', 'hermes', 'openclaw', 'workbuddy', 'pi', 'kimi', 'mimo', 'claude', 'qwen', 'gemini',
|
||||
'codex', 'hermes', 'openclaw', 'workbuddy', 'zcode', 'pi', 'kimi', 'mimo', 'claude', 'qwen', 'gemini',
|
||||
'opencode', 'opencodereview',
|
||||
])
|
||||
const installCatalog = ref({ platform: '', agents: [] })
|
||||
@@ -585,10 +614,8 @@ const {
|
||||
snapshot,
|
||||
})
|
||||
const { messageDeleteArmedId } = messageActions
|
||||
// Agent discovery no longer blocks the window: history is local and usable at
|
||||
// once, and the sidebar, home and composer each show that detection is running.
|
||||
const contentInteractionBlocked = computed(() => (
|
||||
blockingOverlayOpen.value || traceDrawerBlocking.value
|
||||
blockingOverlayOpen.value || traceDrawerBlocking.value || agentDiscoveryPending.value
|
||||
))
|
||||
const composerContext = useComposerContext({
|
||||
activeGroup,
|
||||
|
||||
@@ -222,7 +222,7 @@ describe('Meldwork workbench', () => {
|
||||
await betaButton.trigger('click')
|
||||
expect(betaButton.classes()).toContain('active')
|
||||
expect(wrapper.get('.composer-box textarea').attributes('placeholder'))
|
||||
.toBe('Beta: Agents @ each other into the discussion and the model orchestrates the rounds autonomously.')
|
||||
.toBe('Beta: Agents request peer contributions, advance the work, and check results. Step in when they need information, permission, or a key decision.')
|
||||
|
||||
await wrapper.get('.composer-box textarea').setValue('Plan a launch')
|
||||
await wrapper.get('.send-button').trigger('click')
|
||||
|
||||
@@ -43,6 +43,22 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('Meldwork workbench', () => {
|
||||
it('keeps one settings navigation shell across all tabs without a visible title block', async () => {
|
||||
const { wrapper } = await mountApp()
|
||||
await wrapper.get('.sidebar-settings-entry').trigger('click')
|
||||
const body = wrapper.get('.system-settings-body').element
|
||||
const navigation = wrapper.get('.settings-tabs').element
|
||||
for (const index of [0, 3, 1, 2, 0]) {
|
||||
await wrapper.findAll('.settings-tabs button')[index].trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.system-settings-body').element).toBe(body)
|
||||
expect(wrapper.get('.settings-tabs').element).toBe(navigation)
|
||||
expect(wrapper.find('.system-settings-header').exists()).toBe(false)
|
||||
expect(wrapper.findAll('.settings-tabs [aria-selected="true"]')).toHaveLength(1)
|
||||
expect(wrapper.findAll('.settings-tabs button')[index].attributes('aria-selected')).toBe('true')
|
||||
}
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('provides a dedicated cloud-server settings tab', async () => {
|
||||
const { wrapper } = await mountApp()
|
||||
|
||||
@@ -66,7 +82,7 @@ describe('Meldwork workbench', () => {
|
||||
})
|
||||
|
||||
expect(wrapper.get('.onboarding-dialog').attributes('aria-modal')).toBe('true')
|
||||
expect(wrapper.findAll('.onboarding-dot')).toHaveLength(4)
|
||||
expect(wrapper.findAll('.onboarding-dot')).toHaveLength(5)
|
||||
expect(wrapper.get('.onboarding-slide img').attributes('src')).toContain('discover-local-agents-meldwork.png')
|
||||
expect(wrapper.get('.onboarding-primary').attributes()).toHaveProperty('disabled')
|
||||
expect(wrapper.get('.onboarding-primary').text()).toContain('Detecting local Agents')
|
||||
|
||||
@@ -45,7 +45,7 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('Meldwork workbench', () => {
|
||||
it('keeps the workspace usable while the first Agent detection runs', async () => {
|
||||
it('shows the startup animation and blocks the workspace until Agent detection finishes', async () => {
|
||||
const fixture = createBridge()
|
||||
const pendingRefresh = deferred()
|
||||
fixture.state.agents = []
|
||||
@@ -55,16 +55,11 @@ describe('Meldwork workbench', () => {
|
||||
const wrapper = mount(App, { attachTo: document.body })
|
||||
await flushPromises()
|
||||
|
||||
// No blocking overlay: local history stays reachable and nothing is inert.
|
||||
expect(wrapper.find('.agent-discovery-overlay').exists()).toBe(false)
|
||||
expect(wrapper.get('.workspace-pane').attributes()).not.toHaveProperty('inert')
|
||||
expect(wrapper.get('.sidebar').attributes()).not.toHaveProperty('inert')
|
||||
expect(wrapper.get('.sidebar-discovery').attributes('role')).toBe('status')
|
||||
expect(wrapper.get('.sidebar-discovery').text()).toContain('Detecting local Agents')
|
||||
// An empty Agent list is not a setup problem yet.
|
||||
expect(wrapper.get('.home-workspace-state').text()).toBe('Detecting local Agents')
|
||||
expect(wrapper.get('.home-workspace-state').classes()).not.toContain('attention')
|
||||
expect(wrapper.find('.setup-guide').exists()).toBe(false)
|
||||
expect(wrapper.get('.agent-discovery-overlay').attributes('role')).toBe('status')
|
||||
expect(wrapper.get('.agent-discovery-overlay').text()).toContain('Agent CLI')
|
||||
expect(wrapper.find('.pixel-blast-container').exists()).toBe(true)
|
||||
expect(wrapper.get('.workspace-pane').attributes()).toHaveProperty('inert')
|
||||
expect(wrapper.get('.sidebar').attributes()).toHaveProperty('inert')
|
||||
|
||||
pendingRefresh.resolve(structuredClone({
|
||||
...fixture.state,
|
||||
@@ -72,11 +67,30 @@ describe('Meldwork workbench', () => {
|
||||
}))
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.agent-discovery-overlay').exists()).toBe(false)
|
||||
expect(wrapper.get('.workspace-pane').attributes()).not.toHaveProperty('inert')
|
||||
expect(wrapper.get('.sidebar').attributes()).not.toHaveProperty('inert')
|
||||
expect(wrapper.find('.sidebar-discovery').exists()).toBe(false)
|
||||
expect(wrapper.get('.home-workspace-state').text()).not.toBe('Detecting local Agents')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('leaves the startup animation and shows an error if detection fails', async () => {
|
||||
const pendingRefresh = deferred()
|
||||
const { wrapper } = await mountApp(({ bridge }) => {
|
||||
bridge.localWorkspace.refreshAgents.mockReturnValueOnce(pendingRefresh.promise)
|
||||
})
|
||||
expect(wrapper.find('.agent-discovery-overlay').exists()).toBe(true)
|
||||
|
||||
pendingRefresh.reject(new Error('LOCAL_AGENT_PROCESS_FAILED'))
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.agent-discovery-overlay').exists()).toBe(false)
|
||||
expect(wrapper.get('.workspace-pane').attributes()).not.toHaveProperty('inert')
|
||||
expect(wrapper.get('.toast-message').attributes('role')).toBe('alert')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows the workspace home, keeps full Agent management in Settings, and switches language and theme', async () => {
|
||||
const { wrapper } = await mountApp()
|
||||
|
||||
@@ -96,15 +110,15 @@ describe('Meldwork workbench', () => {
|
||||
await wrapper.get('.sidebar-settings-entry').trigger('click')
|
||||
expect(wrapper.findAll('.agent-card')).toHaveLength(AGENTS.length)
|
||||
expect(wrapper.get('.agent-manager').text()).toContain('OpenCodeReview')
|
||||
expect(wrapper.get('.system-settings-header h1').text()).toBe('Settings')
|
||||
expect(wrapper.get('.system-settings-header p').text()).toContain('knowledge bases')
|
||||
expect(wrapper.get('.system-settings-page').attributes('aria-label')).toBe('Settings')
|
||||
expect(wrapper.find('.system-settings-header').exists()).toBe(false)
|
||||
expect(wrapper.get('.sidebar-settings-entry').attributes('aria-current')).toBe('page')
|
||||
expect(wrapper.get('.brand-button').attributes()).not.toHaveProperty('aria-current')
|
||||
|
||||
const controls = wrapper.findAll('.titlebar-preferences button')
|
||||
await controls[0].trigger('click')
|
||||
expect(wrapper.get('.system-settings-header h1').text()).toBe('设置')
|
||||
expect(wrapper.get('.system-settings-header p').text()).toContain('知识库')
|
||||
expect(wrapper.get('.system-settings-page').attributes('aria-label')).toBe('设置')
|
||||
expect(wrapper.find('.system-settings-header').exists()).toBe(false)
|
||||
|
||||
await controls[1].trigger('click')
|
||||
expect(document.documentElement.dataset.theme).toBe('dark')
|
||||
@@ -311,7 +325,7 @@ describe('Meldwork workbench', () => {
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_800)
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.conversation-empty-copy').text()).toContain('再把 Agent 们叫到一起')
|
||||
expect(wrapper.get('.conversation-empty-copy').text()).toContain('提出目标,让 Agent 们协作完成')
|
||||
|
||||
await wrapper.findAll('.titlebar-preferences button')[1].trigger('click')
|
||||
expect(wrapper.get('.conversation-empty-wordmark').attributes('src')).toBe('./logos/meldwork-wordmark-v3-dark.svg')
|
||||
@@ -482,12 +496,14 @@ describe('Meldwork workbench', () => {
|
||||
|
||||
it('collapses the sidebar while keeping Agent logos and workspace preferences available', async () => {
|
||||
const { wrapper } = await mountApp()
|
||||
// The fixture installs WorkBuddy but has no usable WorkBuddy runtime.
|
||||
const visibleCount = AGENTS.length - 1
|
||||
|
||||
expect(wrapper.find('.brand-actions').exists()).toBe(false)
|
||||
expect(wrapper.findAll('.titlebar-preferences button')).toHaveLength(2)
|
||||
expect(wrapper.findAll('.titlebar-preferences .preference-icon-frame')).toHaveLength(2)
|
||||
expect(wrapper.findAll('.nav-heading svg')).toHaveLength(0)
|
||||
expect(wrapper.findAll('.sidebar-agent-main img')).toHaveLength(AGENTS.length)
|
||||
expect(wrapper.findAll('.sidebar-agent-main img')).toHaveLength(visibleCount)
|
||||
const [agentsToggle, groupsToggle] = wrapper.findAll('.nav-heading')
|
||||
expect(agentsToggle.attributes('aria-expanded')).toBe('true')
|
||||
await agentsToggle.trigger('click')
|
||||
@@ -499,12 +515,12 @@ describe('Meldwork workbench', () => {
|
||||
await groupsToggle.trigger('click')
|
||||
expect(wrapper.get('#sidebar-group-list').exists()).toBe(true)
|
||||
await agentsToggle.trigger('click')
|
||||
expect(wrapper.findAll('.sidebar-agent-main img')).toHaveLength(AGENTS.length)
|
||||
expect(wrapper.findAll('.sidebar-agent-main img')).toHaveLength(visibleCount)
|
||||
|
||||
await wrapper.get('.sidebar-toggle').trigger('click')
|
||||
expect(wrapper.get('.app-shell').classes()).toContain('sidebar-collapsed')
|
||||
expect(wrapper.get('.sidebar').classes()).toContain('collapsed')
|
||||
expect(wrapper.findAll('.sidebar-agent-main img')).toHaveLength(AGENTS.length)
|
||||
expect(wrapper.findAll('.sidebar-agent-main img')).toHaveLength(visibleCount)
|
||||
|
||||
await wrapper.get('.sidebar-toggle').trigger('click')
|
||||
expect(wrapper.get('.app-shell').classes()).not.toContain('sidebar-collapsed')
|
||||
@@ -836,20 +852,17 @@ describe('Meldwork workbench', () => {
|
||||
expect(source).toMatch(/\.composer-zone\s*\{[^}]*background:\s*transparent;/s)
|
||||
})
|
||||
|
||||
it('keeps the first-run onboarding transition and loads no WebGL at startup', () => {
|
||||
it('keeps the first-run onboarding transition and the original startup animations', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/styles.css'), 'utf8')
|
||||
const appSource = readFileSync(resolve(process.cwd(), 'src/App.vue'), 'utf8')
|
||||
const packageSource = readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')
|
||||
|
||||
expect(appSource).toContain('<transition name="onboarding" appear>')
|
||||
// Startup used to stack a physics loader and a full-screen WebGL field; it is one small bar now.
|
||||
expect(appSource).not.toContain('PixelBlast')
|
||||
expect(appSource).not.toContain('WavePhysicsLoader')
|
||||
expect(appSource).not.toContain('agent-discovery-overlay')
|
||||
expect(appSource).toContain('<span class="boot-progress" aria-hidden="true"><span /></span>')
|
||||
expect(packageSource).not.toContain('"three"')
|
||||
expect(source).not.toContain('--pixel-blast-')
|
||||
expect(source).toMatch(/@keyframes boot-progress\s*\{[^}]*transform:\s*translateX/s)
|
||||
expect(appSource).toContain('<PixelBlast :theme="theme" />')
|
||||
expect(appSource).toContain('<WavePhysicsLoader :theme="theme" />')
|
||||
expect(appSource).toContain('agent-discovery-overlay')
|
||||
expect(packageSource).toContain('"three"')
|
||||
expect(source).toContain('--pixel-blast-')
|
||||
expect(source).toMatch(/\.onboarding-leave-active\s*\{[^}]*transition:\s*opacity 0\.42s ease;/s)
|
||||
expect(source).toMatch(/\.onboarding-leave-active \.onboarding-backdrop\s*\{[^}]*transition:\s*background-color 0\.42s ease;/s)
|
||||
expect(source).toMatch(/\.onboarding-leave-to\s*\{[^}]*opacity:\s*0;/s)
|
||||
@@ -889,8 +902,9 @@ describe('Meldwork workbench', () => {
|
||||
|
||||
expect(source).toMatch(/html,\s*body,\s*#app\s*\{[^}]*scrollbar-width:\s*none;[^}]*-ms-overflow-style:\s*none;/s)
|
||||
expect(source).toMatch(/html::-webkit-scrollbar,[^}]+body::-webkit-scrollbar,[^}]+#app::-webkit-scrollbar,[^}]+\*::\-webkit-scrollbar\s*\{[^}]*width:\s*0;[^}]*height:\s*0;/s)
|
||||
// Long sidebar lists, run details and settings show where you are; the chat page and dialogs stay clean.
|
||||
expect(source).toMatch(/\.conversation-nav,[^{]*\.trace-panel-scroll,[^{]*\.settings-panel,[^{]*\{[^}]*scrollbar-width:\s*thin;[^}]*scrollbar-color:\s*var\(--scrollbar-thumb\) transparent;/s)
|
||||
// Only run details and settings keep visible scrollbars; sidebar scrolling stays available.
|
||||
expect(source).toMatch(/\.trace-panel-scroll,[^{]*\.settings-panel,[^{]*\{[^}]*scrollbar-width:\s*thin;[^}]*scrollbar-color:\s*var\(--scrollbar-thumb\) transparent;/s)
|
||||
expect(source).not.toMatch(/\.conversation-nav,[^{]*\{[^}]*scrollbar-width:\s*thin;/s)
|
||||
expect(source).not.toMatch(/\.message-scroll,[^{]*\{[^}]*scrollbar-width:\s*thin;/s)
|
||||
expect(source).not.toMatch(/\.modal-body,[^{]*\{[^}]*scrollbar-width:\s*thin;/s)
|
||||
})
|
||||
@@ -1882,7 +1896,7 @@ describe('Meldwork workbench', () => {
|
||||
Object.assign(state.agents.find(agent => agent.kind === 'hermes'), {
|
||||
available: false,
|
||||
credentialState: 'missing',
|
||||
showInSidebar: false,
|
||||
showInSidebar: true,
|
||||
})
|
||||
state.groups.push({
|
||||
id: 'direct-hermes',
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import WorkBuddySettings from '../../components/WorkBuddySettings.vue'
|
||||
import { locale } from '../../i18n.js'
|
||||
|
||||
afterEach(() => { delete window.meldworkDesktop })
|
||||
|
||||
describe('WorkBuddy model settings', () => {
|
||||
it('loads names, saves only the selected ID and refreshes after success', async () => {
|
||||
locale.value = 'en'
|
||||
const settings = { modelId: '', models: [{ id: 'text', name: 'Text model' }] }
|
||||
const setModel = vi.fn(async modelId => ({ ...settings, modelId }))
|
||||
window.meldworkDesktop = { workBuddy: { settings: vi.fn(async () => settings), setModel } }
|
||||
const wrapper = mount(WorkBuddySettings)
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('Text model')
|
||||
expect(wrapper.get('button[type="submit"]').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('select').setValue('text')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(setModel).toHaveBeenCalledWith('text')
|
||||
expect(wrapper.emitted('changed')).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps the choice and reports failure without claiming success', async () => {
|
||||
locale.value = 'zh'
|
||||
window.meldworkDesktop = { workBuddy: {
|
||||
settings: vi.fn(async () => ({ modelId: '', models: [{ id: 'text', name: 'Text' }] })),
|
||||
setModel: vi.fn(async () => { throw new Error('LOCAL_WORKBUDDY_BUSY') }),
|
||||
} }
|
||||
const wrapper = mount(WorkBuddySettings)
|
||||
await flushPromises()
|
||||
await wrapper.get('select').setValue('text')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[role="alert"]').text()).toContain('等待当前运行结束')
|
||||
expect(wrapper.get('select').element.value).toBe('text')
|
||||
expect(wrapper.emitted('changed')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import WorkPreferences from '../../components/WorkPreferences.vue'
|
||||
import { locale } from '../../i18n.js'
|
||||
|
||||
afterEach(() => { delete window.meldworkDesktop })
|
||||
const defaults = { detail: 50, initiative: 50, evidence: 50, preferredAgentKinds: [], instructions: '' }
|
||||
function setup(save = vi.fn(async value => value)) {
|
||||
locale.value = 'en'
|
||||
window.meldworkDesktop = {
|
||||
workPreferences: { get: vi.fn(async () => defaults), save },
|
||||
localWorkspace: { get: vi.fn(async () => ({ agents: [{ kind: 'codex', installed: true }] })) },
|
||||
}
|
||||
return { wrapper: mount(WorkPreferences), save }
|
||||
}
|
||||
|
||||
it('lets users preview a preset, customize and explicitly save bounded preferences', async () => {
|
||||
const { wrapper, save } = setup()
|
||||
await flushPromises()
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
await wrapper.get('.work-presets button').trigger('click')
|
||||
await wrapper.get('textarea').setValue('Give me a comparison table.')
|
||||
await wrapper.get('input[type="checkbox"]').setValue(true)
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(save).toHaveBeenCalledWith({ ...defaults, detail: 15, initiative: 85, preferredAgentKinds: ['codex'], instructions: 'Give me a comparison table.' })
|
||||
expect(wrapper.find('[role="status"]').exists()).toBe(true)
|
||||
await wrapper.get('textarea').setValue('Changed')
|
||||
expect(wrapper.find('[role="status"]').exists()).toBe(false)
|
||||
expect(wrapper.get('textarea').attributes('maxlength')).toBe('800')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps unsaved choices on a busy error and resets only the form', async () => {
|
||||
const save = vi.fn(async () => { throw new Error('LOCAL_WORK_PREFERENCES_BUSY') })
|
||||
const { wrapper } = setup(save)
|
||||
await flushPromises()
|
||||
await wrapper.get('textarea').setValue('Keep this draft')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[role="alert"]').text()).toBeTruthy()
|
||||
expect(wrapper.get('textarea').element.value).toBe('Keep this draft')
|
||||
expect(wrapper.find('[role="status"]').exists()).toBe(false)
|
||||
await wrapper.get('.work-preferences-actions button[type="button"]').trigger('click')
|
||||
expect(wrapper.get('textarea').element.value).toBe('')
|
||||
expect(save).toHaveBeenCalledTimes(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('offers five distinct borderless presets and updates every dial on input before saving', async () => {
|
||||
const { wrapper, save } = setup()
|
||||
await flushPromises()
|
||||
const presets = wrapper.findAll('.work-presets button')
|
||||
expect(presets).toHaveLength(5)
|
||||
const combinations = new Set()
|
||||
for (const preset of presets) {
|
||||
await preset.trigger('click')
|
||||
combinations.add(wrapper.findAll('input[type="range"]').map(input => input.element.value).join(','))
|
||||
expect(preset.attributes('aria-pressed')).toBe('true')
|
||||
expect(preset.classes()).not.toContain('secondary-button')
|
||||
}
|
||||
expect(combinations.size).toBe(5)
|
||||
const ranges = wrapper.findAll('input[type="range"]')
|
||||
for (const [index, input] of ranges.entries()) {
|
||||
input.element.value = String([37, 62, 91][index])
|
||||
await input.trigger('input')
|
||||
expect(input.attributes('aria-valuetext')).toBe(`${[37, 62, 91][index]}/100`)
|
||||
}
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(save).toHaveBeenCalledWith({ ...defaults, detail: 37, initiative: 62, evidence: 91 })
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not render unsupported controls without the desktop bridge', () => {
|
||||
const wrapper = mount(WorkPreferences)
|
||||
expect(wrapper.find('section').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('migrates old three-level settings and shows native Agent logos with distinct labels', async () => {
|
||||
const { wrapper } = setup()
|
||||
wrapper.unmount()
|
||||
window.meldworkDesktop.workPreferences.get = async () => ({ ...defaults, detail: 'concise', initiative: 'balanced', evidence: 'strict' })
|
||||
window.meldworkDesktop.localWorkspace.get = async () => ({ agents: [
|
||||
{ kind: 'codex', installed: true },
|
||||
{ kind: 'cloud-example', sourceKind: 'codex', label: 'Codex @ Server', installed: true },
|
||||
] })
|
||||
const active = mount(WorkPreferences)
|
||||
await flushPromises()
|
||||
expect(active.findAll('input[type="range"]').map(input => input.element.value)).toEqual(['0', '50', '100'])
|
||||
expect(active.findAll('.work-agent-choice img')).toHaveLength(2)
|
||||
expect(active.findAll('.work-agent-choice img')[1].attributes('src')).toContain('codex')
|
||||
expect(active.text()).toContain('Codex @ Server')
|
||||
active.unmount()
|
||||
})
|
||||
|
||||
it('updates collaborators when startup detection finishes without overwriting the draft', async () => {
|
||||
const { wrapper } = setup()
|
||||
let changed
|
||||
const unsubscribe = vi.fn()
|
||||
window.meldworkDesktop.localWorkspace.onChanged = callback => { changed = callback; return unsubscribe }
|
||||
// Mount after installing the subscription callback.
|
||||
wrapper.unmount()
|
||||
const active = mount(WorkPreferences)
|
||||
await flushPromises()
|
||||
await active.get('textarea').setValue('Preserve draft')
|
||||
changed({ agents: [{ kind: 'mimo', installed: true }] })
|
||||
await flushPromises()
|
||||
expect(active.get('input[type="checkbox"]').element.value).toBe('mimo')
|
||||
expect(active.get('textarea').element.value).toBe('Preserve draft')
|
||||
active.unmount()
|
||||
expect(unsubscribe).toHaveBeenCalledOnce()
|
||||
})
|
||||
@@ -18,6 +18,26 @@ function catalogFor(agent) {
|
||||
}
|
||||
|
||||
describe('Agent compatibility state', () => {
|
||||
it('honors explicit hiding despite history and excludes unavailable WorkBuddy until it becomes ready', () => {
|
||||
const snapshot = ref({ agents: [
|
||||
{ kind: 'hermes', installed: true, available: false, showInSidebar: false },
|
||||
{ kind: 'workbuddy', installed: true, available: false, showInSidebar: true, availabilitySource: 'workbuddy-desktop-locked' },
|
||||
] })
|
||||
const catalog = useAgentCatalog({ activeGroup: ref(null), directGroupsFor: () => [{ id: 'old-chat' }],
|
||||
installCatalog: ref({ agents: [] }), snapshot, t, theme: ref('light') })
|
||||
expect(catalog.sidebarAgents.value.map(agent => agent.kind)).not.toContain('hermes')
|
||||
expect(catalog.sidebarAgents.value.map(agent => agent.kind)).not.toContain('workbuddy')
|
||||
expect(catalog.mergedCatalog.value.find(agent => agent.kind === 'workbuddy').installed).toBe(true)
|
||||
snapshot.value.agents[1].available = true
|
||||
expect(catalog.sidebarAgents.value.map(agent => agent.kind)).toContain('workbuddy')
|
||||
snapshot.value.agents[1].showInSidebar = false
|
||||
expect(catalog.sidebarAgents.value.map(agent => agent.kind)).not.toContain('workbuddy')
|
||||
})
|
||||
it('explains WorkBuddy host-encrypted credentials without reporting a desktop sign-out', () => {
|
||||
setLocale('en')
|
||||
const agent = { kind: 'workbuddy', installed: true, available: false, credentialState: 'missing', availabilitySource: 'workbuddy-desktop-locked' }
|
||||
expect(catalogFor(agent).label).toBe('CLI setup needed')
|
||||
})
|
||||
it('keeps installed unavailable Agents visible while respecting hidden preferences and runnable choices', () => {
|
||||
const snapshot = ref({ agents: [
|
||||
{ kind: 'codex', installed: true, available: false, credentialState: 'missing', showInSidebar: true },
|
||||
|
||||
@@ -50,6 +50,20 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('composer context', () => {
|
||||
it('keeps the selected discussion mode when the current group membership refreshes', () => {
|
||||
const { activeGroup, composer, scope } = createComposer()
|
||||
activeGroup.value = { id: 'group-auto', agentKinds: ['codex', 'hermes'] }
|
||||
composer.resetComposerContext(activeGroup.value)
|
||||
composer.discussionMode.value = 'auto-beta'
|
||||
composer.resetComposerContext({ ...activeGroup.value, agentKinds: ['hermes', 'codex'] })
|
||||
expect(composer.discussionMode.value).toBe('auto-beta')
|
||||
composer.resetComposerContext({ id: 'other-group', agentKinds: ['codex', 'hermes'] })
|
||||
expect(composer.discussionMode.value).toBe('auto')
|
||||
composer.resetComposerContext(activeGroup.value)
|
||||
expect(composer.discussionMode.value).toBe('auto-beta')
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('treats an empty manual selection as the whole group', () => {
|
||||
const { activeGroup, composer, scope } = createComposer()
|
||||
activeGroup.value = {
|
||||
|
||||
@@ -141,6 +141,8 @@ describe('Meldwork i18n', () => {
|
||||
.toBe('Sign in to this Agent or configure its credentials, then try again.')
|
||||
expect(translateError(new Error('LOCAL_AGENT_PROCESS_FAILED')))
|
||||
.toBe('The Agent process failed before completing the task.')
|
||||
expect(translateError(new Error('LOCAL_AGENT_REPEATED_OUTPUT')))
|
||||
.toContain('kept repeating the same response')
|
||||
expect(translateError(new Error('LOCAL_AGENT_TIMEOUT')))
|
||||
.toBe('This Agent took too long to respond. Try again or start a new conversation.')
|
||||
expect(translateSystemMessage(systemMessage('LOCAL_AGENT_EXECUTION_STOPPED')))
|
||||
@@ -159,6 +161,8 @@ describe('Meldwork i18n', () => {
|
||||
.toBe('请先登录该 Agent 或完成凭据配置后再试。')
|
||||
expect(translateError(new Error('LOCAL_AGENT_PROCESS_FAILED')))
|
||||
.toBe('Agent 进程在任务完成前执行失败。')
|
||||
expect(translateError(new Error('LOCAL_AGENT_REPEATED_OUTPUT')))
|
||||
.toContain('持续重复相同回复')
|
||||
expect(translateError(new Error('LOCAL_AGENT_TIMEOUT')))
|
||||
.toBe('该 Agent 响应超时,请重试或新建会话后继续。')
|
||||
expect(translateSystemMessage(systemMessage('LOCAL_AGENT_EXECUTION_STOPPED')))
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('renderer security policy', () => {
|
||||
expect(AGENTS.every(agent => agent.logo.startsWith('./agent-logos/'))).toBe(true)
|
||||
expect(AGENTS.some(agent => agent.kind === 'mimo')).toBe(true)
|
||||
expect(AGENTS.map(agent => agent.kind)).toEqual([
|
||||
'codex', 'claude', 'hermes', 'workbuddy', 'openclaw', 'pi',
|
||||
'codex', 'claude', 'hermes', 'workbuddy', 'openclaw', 'zcode', 'pi',
|
||||
'opencode', 'opencodereview', 'kimi', 'mimo', 'qwen',
|
||||
])
|
||||
expect(AGENTS.at(-1)?.kind).toBe('qwen')
|
||||
|
||||
@@ -15,6 +15,8 @@ export const AGENTS = Object.freeze([
|
||||
},
|
||||
{ kind: 'workbuddy', label: 'WorkBuddy', logo: publicAsset('agent-logos/workbuddy.png'), imageLimit: 0, attachmentTypes: ['file'] },
|
||||
{ kind: 'openclaw', label: 'OpenClaw', logo: publicAsset('agent-logos/openclaw-transparent.png'), imageLimit: 0, attachmentTypes: ['file'] },
|
||||
// Official asset: https://zcode.z.ai/favicon-192x192.png?v=20260707-transparent
|
||||
{ kind: 'zcode', label: 'Zcode', logo: publicAsset('agent-logos/zcode.png'), imageLimit: 0, attachmentTypes: ['file'] },
|
||||
{ kind: 'pi', label: 'Pi Agent', logo: publicAsset('agent-logos/pi.svg'), imageLimit: 0, attachmentTypes: ['file'] },
|
||||
{ kind: 'opencode', label: 'OpenCode', logo: publicAsset('agent-logos/opencode.svg'), imageLimit: 4, attachmentTypes: ['file'] },
|
||||
{ kind: 'opencodereview', label: 'OpenCodeReview', logo: publicAsset('agent-logos/opencodereview.svg'), imageLimit: 0 },
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
{{ t('home.refresh') }}
|
||||
</button>
|
||||
</p>
|
||||
<WorkBuddySettings v-if="agent.kind === 'workbuddy' && agent.installed" @changed="$emit('refresh-agents')" />
|
||||
<div class="agent-card-meta-row">
|
||||
<span
|
||||
v-if="agent.version"
|
||||
@@ -124,6 +125,7 @@ import {
|
||||
RefreshOutline,
|
||||
} from '@vicons/ionicons5'
|
||||
import { t } from '../i18n.js'
|
||||
import WorkBuddySettings from './WorkBuddySettings.vue'
|
||||
|
||||
defineProps({
|
||||
agentCatalogGroups: { type: Array, required: true },
|
||||
|
||||
@@ -1,46 +1,49 @@
|
||||
<template>
|
||||
<section class="settings-panel general-settings-panel">
|
||||
<div class="general-settings-row">
|
||||
<div class="general-settings-copy">
|
||||
<h2 id="general-settings-language">{{ t('generalSettings.languageTitle') }}</h2>
|
||||
<p>{{ t('generalSettings.languageHint') }}</p>
|
||||
<div class="general-interface-settings">
|
||||
<div class="general-settings-row">
|
||||
<div class="general-settings-copy">
|
||||
<h2 id="general-settings-language">{{ t('generalSettings.languageTitle') }}</h2>
|
||||
<p>{{ t('generalSettings.languageHint') }}</p>
|
||||
</div>
|
||||
<div class="choice-segmented" role="radiogroup" aria-labelledby="general-settings-language">
|
||||
<button
|
||||
v-for="option in languageOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
role="radio"
|
||||
:aria-checked="String(locale === option.value)"
|
||||
:class="{ active: locale === option.value }"
|
||||
:lang="option.lang"
|
||||
@click="setLocale(option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="choice-segmented" role="radiogroup" aria-labelledby="general-settings-language">
|
||||
<button
|
||||
v-for="option in languageOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
role="radio"
|
||||
:aria-checked="String(locale === option.value)"
|
||||
:class="{ active: locale === option.value }"
|
||||
:lang="option.lang"
|
||||
@click="setLocale(option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="general-settings-row">
|
||||
<div class="general-settings-copy">
|
||||
<h2 id="general-settings-theme">{{ t('generalSettings.themeTitle') }}</h2>
|
||||
<p>{{ t('generalSettings.themeHint') }}</p>
|
||||
</div>
|
||||
<div class="choice-segmented" role="radiogroup" aria-labelledby="general-settings-theme">
|
||||
<button
|
||||
v-for="option in themeOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
role="radio"
|
||||
:aria-checked="String(themePreference === option.value)"
|
||||
:class="{ active: themePreference === option.value }"
|
||||
@click="setThemePreference(option.value)"
|
||||
>
|
||||
<component :is="option.icon" aria-hidden="true" />
|
||||
{{ t(option.labelKey) }}
|
||||
</button>
|
||||
<div class="general-settings-row">
|
||||
<div class="general-settings-copy">
|
||||
<h2 id="general-settings-theme">{{ t('generalSettings.themeTitle') }}</h2>
|
||||
<p>{{ t('generalSettings.themeHint') }}</p>
|
||||
</div>
|
||||
<div class="choice-segmented" role="radiogroup" aria-labelledby="general-settings-theme">
|
||||
<button
|
||||
v-for="option in themeOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
role="radio"
|
||||
:aria-checked="String(themePreference === option.value)"
|
||||
:class="{ active: themePreference === option.value }"
|
||||
@click="setThemePreference(option.value)"
|
||||
>
|
||||
<component :is="option.icon" aria-hidden="true" />
|
||||
{{ t(option.labelKey) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<WorkPreferences />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -48,6 +51,7 @@
|
||||
import { computed, unref } from 'vue'
|
||||
import { DesktopOutline, MoonOutline, SunnyOutline } from '@vicons/ionicons5'
|
||||
import { locale as currentLocale, setLocale, t } from '../i18n.js'
|
||||
import WorkPreferences from './WorkPreferences.vue'
|
||||
|
||||
const props = defineProps({
|
||||
setThemePreference: { type: Function, required: true },
|
||||
|
||||
@@ -12,11 +12,12 @@
|
||||
>
|
||||
<div class="onboarding-slide-viewport">
|
||||
<transition name="onboarding-slide">
|
||||
<article :key="onboardingIndex" class="onboarding-slide">
|
||||
<img :src="onboardingSlide.image" alt="" />
|
||||
<article :key="onboardingIndex" class="onboarding-slide" :class="{ 'preferences-slide': onboardingSlide.preferences }">
|
||||
<img v-if="!onboardingSlide.preferences" :src="onboardingSlide.image" alt="" />
|
||||
<div>
|
||||
<h1 id="onboarding-title">{{ onboardingSlide.title }}</h1>
|
||||
<p>{{ onboardingSlide.body }}</p>
|
||||
<WorkPreferences v-if="onboardingSlide.preferences" />
|
||||
</div>
|
||||
</article>
|
||||
</transition>
|
||||
@@ -58,6 +59,7 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { CheckmarkCircleOutline } from '@vicons/ionicons5'
|
||||
import WorkPreferences from './WorkPreferences.vue'
|
||||
|
||||
const props = defineProps({
|
||||
controller: { type: Object, required: true },
|
||||
@@ -82,3 +84,9 @@ defineExpose({
|
||||
querySelectorAll: selector => dialog.value?.querySelectorAll(selector) || [],
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.preferences-slide { grid-template-rows: minmax(0, 1fr); }
|
||||
.preferences-slide :deep(.work-preferences) { margin-top: 20px; }
|
||||
.preferences-slide :deep(.work-preferences h2), .preferences-slide :deep(.work-preferences > p) { display: none; }
|
||||
</style>
|
||||
@@ -0,0 +1,273 @@
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
class="pixel-blast-container"
|
||||
:class="{ 'pixel-blast-ready': rendererReady }"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="pixel-blast-fallback" />
|
||||
<canvas ref="canvas" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
theme: { type: String, default: 'light' },
|
||||
})
|
||||
|
||||
const container = ref(null)
|
||||
const canvas = ref(null)
|
||||
const rendererReady = ref(false)
|
||||
let runtime = null
|
||||
let disposed = false
|
||||
|
||||
const VERTEX_SHADER = `
|
||||
void main() {
|
||||
gl_Position = vec4(position, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
// Adapted from the React Bits PixelBlast shader (MIT).
|
||||
const FRAGMENT_SHADER = `
|
||||
precision highp float;
|
||||
|
||||
uniform vec2 uResolution;
|
||||
uniform float uTime;
|
||||
uniform float uPixelSize;
|
||||
uniform float uScale;
|
||||
uniform float uDensity;
|
||||
uniform float uPixelJitter;
|
||||
uniform float uEdgeFade;
|
||||
uniform float uOpacity;
|
||||
uniform vec3 uPrimaryColor;
|
||||
uniform vec3 uSecondaryColor;
|
||||
uniform vec3 uTertiaryColor;
|
||||
|
||||
out vec4 fragColor;
|
||||
|
||||
float Bayer2(vec2 a) {
|
||||
a = floor(a);
|
||||
return fract(a.x / 2.0 + a.y * a.y * 0.75);
|
||||
}
|
||||
|
||||
#define Bayer4(a) (Bayer2(0.5 * (a)) * 0.25 + Bayer2(a))
|
||||
#define Bayer8(a) (Bayer4(0.5 * (a)) * 0.25 + Bayer2(a))
|
||||
|
||||
float hash11(float n) {
|
||||
return fract(sin(n) * 43758.5453);
|
||||
}
|
||||
|
||||
float vnoise(vec3 p) {
|
||||
vec3 ip = floor(p);
|
||||
vec3 fp = fract(p);
|
||||
float n000 = hash11(dot(ip + vec3(0.0, 0.0, 0.0), vec3(1.0, 57.0, 113.0)));
|
||||
float n100 = hash11(dot(ip + vec3(1.0, 0.0, 0.0), vec3(1.0, 57.0, 113.0)));
|
||||
float n010 = hash11(dot(ip + vec3(0.0, 1.0, 0.0), vec3(1.0, 57.0, 113.0)));
|
||||
float n110 = hash11(dot(ip + vec3(1.0, 1.0, 0.0), vec3(1.0, 57.0, 113.0)));
|
||||
float n001 = hash11(dot(ip + vec3(0.0, 0.0, 1.0), vec3(1.0, 57.0, 113.0)));
|
||||
float n101 = hash11(dot(ip + vec3(1.0, 0.0, 1.0), vec3(1.0, 57.0, 113.0)));
|
||||
float n011 = hash11(dot(ip + vec3(0.0, 1.0, 1.0), vec3(1.0, 57.0, 113.0)));
|
||||
float n111 = hash11(dot(ip + vec3(1.0, 1.0, 1.0), vec3(1.0, 57.0, 113.0)));
|
||||
vec3 weight = fp * fp * fp * (fp * (fp * 6.0 - 15.0) + 10.0);
|
||||
float x00 = mix(n000, n100, weight.x);
|
||||
float x10 = mix(n010, n110, weight.x);
|
||||
float x01 = mix(n001, n101, weight.x);
|
||||
float x11 = mix(n011, n111, weight.x);
|
||||
return mix(mix(x00, x10, weight.y), mix(x01, x11, weight.y), weight.z) * 2.0 - 1.0;
|
||||
}
|
||||
|
||||
float fbm(vec2 uv, float time) {
|
||||
vec3 point = vec3(uv * uScale, time);
|
||||
float amplitude = 1.0;
|
||||
float frequency = 1.0;
|
||||
float sum = 1.0;
|
||||
for (int index = 0; index < 5; ++index) {
|
||||
sum += amplitude * vnoise(point * frequency);
|
||||
frequency *= 1.25;
|
||||
}
|
||||
return sum * 0.5 + 0.5;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 fragCoord = gl_FragCoord.xy - uResolution * 0.5;
|
||||
float aspectRatio = uResolution.x / uResolution.y;
|
||||
vec2 pixelId = floor(fragCoord / uPixelSize);
|
||||
vec2 pixelUv = fract(fragCoord / uPixelSize);
|
||||
float cellPixelSize = 8.0 * uPixelSize;
|
||||
vec2 cellCoord = floor(fragCoord / cellPixelSize) * cellPixelSize;
|
||||
vec2 fieldUv = cellCoord / uResolution * vec2(aspectRatio, 1.0);
|
||||
|
||||
float motionTime = uTime * (1.0 + 0.12 * sin(uTime * 0.17));
|
||||
vec2 drift = vec2(sin(uTime * 0.41), cos(uTime * 0.33)) * 0.045;
|
||||
float field = fbm(fieldUv + drift, motionTime * 0.05) * 0.5 - 0.65;
|
||||
float feed = field + (uDensity - 0.5) * 0.3;
|
||||
float bayer = Bayer8(fragCoord / uPixelSize) - 0.5;
|
||||
float enabled = step(0.5, feed + bayer);
|
||||
float hash = fract(sin(dot(pixelId, vec2(127.1, 311.7))) * 43758.5453);
|
||||
float jitter = 1.0 + (hash - 0.5) * uPixelJitter;
|
||||
float inset = step(0.08, pixelUv.x) * step(0.08, pixelUv.y)
|
||||
* step(pixelUv.x, 0.92) * step(pixelUv.y, 0.92);
|
||||
float mask = enabled * jitter * inset;
|
||||
|
||||
vec2 normalized = gl_FragCoord.xy / uResolution;
|
||||
float edge = min(min(normalized.x, normalized.y), min(1.0 - normalized.x, 1.0 - normalized.y));
|
||||
mask *= smoothstep(0.0, uEdgeFade, edge);
|
||||
float centerDistance = length((normalized - 0.5) * vec2(aspectRatio, 1.0));
|
||||
// Keep the message area visually calm while preserving the animated field
|
||||
// around the edges of the discovery screen.
|
||||
mask *= mix(0.1, 1.0, smoothstep(0.14, 0.46, centerDistance));
|
||||
|
||||
float colorNoise = clamp(0.5 + 0.5 * vnoise(vec3((fieldUv + drift * 1.8) * 1.35, motionTime * 0.035)), 0.0, 1.0);
|
||||
vec3 color = mix(uPrimaryColor, uSecondaryColor, colorNoise);
|
||||
color = mix(color, uTertiaryColor, smoothstep(0.56, 1.0, normalized.y + colorNoise * 0.18));
|
||||
fragColor = vec4(color, mask * uOpacity);
|
||||
}
|
||||
`
|
||||
|
||||
function cssColor(name, fallback) {
|
||||
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
||||
return value || fallback
|
||||
}
|
||||
|
||||
function updateThemeColors() {
|
||||
if (!runtime) return
|
||||
runtime.uniforms.uPrimaryColor.value.set(cssColor('--pixel-blast-primary', '#d92f24'))
|
||||
runtime.uniforms.uSecondaryColor.value.set(cssColor('--pixel-blast-secondary', '#007d91'))
|
||||
runtime.uniforms.uTertiaryColor.value.set(cssColor('--pixel-blast-tertiary', '#ed512f'))
|
||||
}
|
||||
|
||||
function disposeRuntime() {
|
||||
if (!runtime) return
|
||||
cancelAnimationFrame(runtime.animationFrame)
|
||||
runtime.resizeObserver.disconnect()
|
||||
runtime.geometry.dispose()
|
||||
runtime.material.dispose()
|
||||
runtime.renderer.dispose()
|
||||
runtime = null
|
||||
rendererReady.value = false
|
||||
}
|
||||
|
||||
async function initializeRenderer() {
|
||||
if (!container.value || !canvas.value || typeof window.WebGL2RenderingContext === 'undefined') return
|
||||
try {
|
||||
const THREE = await import('three')
|
||||
if (disposed || !container.value || !canvas.value) return
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
canvas: canvas.value,
|
||||
alpha: true,
|
||||
antialias: false,
|
||||
powerPreference: 'high-performance',
|
||||
})
|
||||
renderer.setClearAlpha(0)
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5))
|
||||
const uniforms = {
|
||||
uResolution: { value: new THREE.Vector2(1, 1) },
|
||||
uTime: { value: Math.random() * 1000 },
|
||||
uPixelSize: { value: 3.7 * renderer.getPixelRatio() },
|
||||
uScale: { value: 2.2 },
|
||||
uDensity: { value: 1.26 },
|
||||
uPixelJitter: { value: 0.32 },
|
||||
uEdgeFade: { value: 0.12 },
|
||||
uOpacity: { value: 0.42 },
|
||||
uPrimaryColor: { value: new THREE.Color() },
|
||||
uSecondaryColor: { value: new THREE.Color() },
|
||||
uTertiaryColor: { value: new THREE.Color() },
|
||||
}
|
||||
const scene = new THREE.Scene()
|
||||
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1)
|
||||
const material = new THREE.ShaderMaterial({
|
||||
vertexShader: VERTEX_SHADER,
|
||||
fragmentShader: FRAGMENT_SHADER,
|
||||
uniforms,
|
||||
transparent: true,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
glslVersion: THREE.GLSL3,
|
||||
})
|
||||
const geometry = new THREE.PlaneGeometry(2, 2)
|
||||
scene.add(new THREE.Mesh(geometry, material))
|
||||
|
||||
const resize = () => {
|
||||
const width = Math.max(1, container.value?.clientWidth || 1)
|
||||
const height = Math.max(1, container.value?.clientHeight || 1)
|
||||
renderer.setSize(width, height, false)
|
||||
uniforms.uResolution.value.set(renderer.domElement.width, renderer.domElement.height)
|
||||
uniforms.uPixelSize.value = 3.7 * renderer.getPixelRatio()
|
||||
}
|
||||
const resizeObserver = new ResizeObserver(resize)
|
||||
resizeObserver.observe(container.value)
|
||||
runtime = {
|
||||
animationFrame: 0,
|
||||
geometry,
|
||||
material,
|
||||
renderer,
|
||||
resizeObserver,
|
||||
uniforms,
|
||||
}
|
||||
resize()
|
||||
updateThemeColors()
|
||||
rendererReady.value = true
|
||||
|
||||
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
let previousFrame = performance.now()
|
||||
const animate = (now) => {
|
||||
if (!runtime) return
|
||||
runtime.uniforms.uTime.value += Math.min(32, now - previousFrame) * 0.00036
|
||||
previousFrame = now
|
||||
renderer.render(scene, camera)
|
||||
if (!reducedMotion) runtime.animationFrame = requestAnimationFrame(animate)
|
||||
}
|
||||
runtime.animationFrame = requestAnimationFrame(animate)
|
||||
} catch {
|
||||
disposeRuntime()
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.theme, () => requestAnimationFrame(updateThemeColors))
|
||||
onMounted(() => { void initializeRenderer() })
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true
|
||||
disposeRuntime()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pixel-blast-container,
|
||||
.pixel-blast-container canvas,
|
||||
.pixel-blast-fallback {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.pixel-blast-container {
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pixel-blast-container canvas {
|
||||
display: block;
|
||||
opacity: 0;
|
||||
transition: opacity 0.22s ease;
|
||||
}
|
||||
|
||||
.pixel-blast-ready canvas {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.pixel-blast-fallback {
|
||||
background-image:
|
||||
repeating-linear-gradient(90deg, transparent 0 5px, color-mix(in srgb, var(--pixel-blast-secondary) 38%, transparent) 5px 8px),
|
||||
repeating-linear-gradient(0deg, transparent 0 5px, color-mix(in srgb, var(--pixel-blast-primary) 34%, transparent) 5px 8px);
|
||||
opacity: 0.32;
|
||||
transition: opacity 0.22s ease;
|
||||
mask-image: linear-gradient(135deg, transparent 2%, black 34%, black 66%, transparent 98%);
|
||||
}
|
||||
|
||||
.pixel-blast-ready .pixel-blast-fallback {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -938,7 +938,7 @@ defineExpose({ focus })
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 20%, var(--border));
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--accent-soft) 52%, var(--surface-raised) 48%);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
<template>
|
||||
<section class="system-settings-page">
|
||||
<header class="system-settings-header">
|
||||
<div>
|
||||
<h1>{{ t('systemSettings.title') }}</h1>
|
||||
<p>{{ t('systemSettings.subtitle') }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="system-settings-page" :aria-label="t('systemSettings.title')">
|
||||
<div class="system-settings-body">
|
||||
<nav class="settings-tabs" role="tablist" :aria-label="t('systemSettings.title')">
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<div class="wave-physics-loader" aria-hidden="true">
|
||||
<div class="wave-physics-track">
|
||||
<span
|
||||
v-for="bar in bars"
|
||||
:key="bar.index"
|
||||
class="wave-physics-bar"
|
||||
:style="{ height: bar.height, backgroundColor: bar.color }"
|
||||
/>
|
||||
<span
|
||||
class="wave-physics-ball"
|
||||
:style="{
|
||||
transform: `translate3d(${ball.x}, ${ball.y}, 0) scaleX(${ball.scaleX}) scaleY(${ball.scaleY})`,
|
||||
backgroundColor: ball.color,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
theme: { type: String, default: 'light' },
|
||||
})
|
||||
|
||||
const numBars = 15
|
||||
const barWidth = 12
|
||||
const barGap = 8
|
||||
const barTotalWidth = barWidth + barGap
|
||||
const B = 4
|
||||
const maxBounce = 60
|
||||
const baseBarH = 16
|
||||
const wavePeakH = 48
|
||||
const duration = 4000
|
||||
|
||||
const progress = ref(0.5)
|
||||
let animationFrame = 0
|
||||
|
||||
function interpolate(start, end, value) {
|
||||
return Math.round(start + (end - start) * value)
|
||||
}
|
||||
|
||||
function barColor(theme, waveValue) {
|
||||
if (theme === 'dark') {
|
||||
return `rgb(${interpolate(39, 228, waveValue)} ${interpolate(39, 228, waveValue)} ${interpolate(42, 231, waveValue)})`
|
||||
}
|
||||
return `rgb(${interpolate(228, 39, waveValue)} ${interpolate(228, 39, waveValue)} ${interpolate(231, 42, waveValue)})`
|
||||
}
|
||||
|
||||
function frameAt(t) {
|
||||
const xFrac = t < 0.5 ? t / 0.5 : (1 - t) / 0.5
|
||||
const ballIndex = xFrac * (numBars - 1)
|
||||
let bounceF = (xFrac * B) % 1
|
||||
if (xFrac === 0 || xFrac === 1) bounceF = 0
|
||||
|
||||
const bounceH = 4 * bounceF * (1 - bounceF)
|
||||
const heightFactor = Math.max(0, 1 - bounceH * 2)
|
||||
const ballIndent = heightFactor * 20
|
||||
const ballY = (baseBarH + wavePeakH - ballIndent) + bounceH * maxBounce
|
||||
|
||||
const bars = Array.from({ length: numBars }, (_unused, index) => {
|
||||
const dist = Math.abs(index - ballIndex)
|
||||
let waveValue = 0
|
||||
if (dist < 3) waveValue = Math.cos((dist / 3) * (Math.PI / 2))
|
||||
|
||||
let indent = 0
|
||||
if (dist < 1.5) {
|
||||
const indentDist = Math.cos((dist / 1.5) * (Math.PI / 2))
|
||||
indent = indentDist * heightFactor * 20
|
||||
}
|
||||
|
||||
const barHeight = Math.max(4, baseBarH + waveValue * wavePeakH - indent)
|
||||
|
||||
return {
|
||||
color: barColor(props.theme, waveValue),
|
||||
height: `${barHeight}px`,
|
||||
index,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
bars,
|
||||
ball: {
|
||||
color: props.theme === 'dark' ? 'rgb(244, 244, 245)' : 'rgb(24, 24, 27)',
|
||||
scaleX: 1 + heightFactor * 0.25,
|
||||
scaleY: 1 - heightFactor * 0.3,
|
||||
x: `${ballIndex * barTotalWidth}px`,
|
||||
y: `-${ballY}px`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const frame = computed(() => frameAt(progress.value))
|
||||
const bars = computed(() => frame.value.bars)
|
||||
const ball = computed(() => frame.value.ball)
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') return
|
||||
if (typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
||||
|
||||
const start = performance.now()
|
||||
|
||||
const tick = (now) => {
|
||||
progress.value = ((now - start) % duration) / duration
|
||||
animationFrame = window.requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
animationFrame = window.requestAnimationFrame(tick)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (typeof window !== 'undefined' && typeof window.cancelAnimationFrame === 'function') {
|
||||
window.cancelAnimationFrame(animationFrame)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.wave-physics-loader {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.wave-physics-track {
|
||||
position: relative;
|
||||
width: 292px;
|
||||
height: 192px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wave-physics-bar {
|
||||
width: 12px;
|
||||
border-radius: 999px;
|
||||
transform-origin: bottom center;
|
||||
will-change: height, background-color;
|
||||
}
|
||||
|
||||
.wave-physics-ball {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
transform-origin: bottom center;
|
||||
box-shadow: 0 1px 3px color-mix(in srgb, currentColor 24%, transparent);
|
||||
will-change: transform, background-color;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.wave-physics-track {
|
||||
width: 260px;
|
||||
transform: scale(0.9);
|
||||
transform-origin: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wave-physics-bar,
|
||||
.wave-physics-ball {
|
||||
will-change: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<details v-if="bridge" class="workbuddy-settings">
|
||||
<summary>{{ t('workbuddy.advanced') }}</summary>
|
||||
<form @submit.prevent="save">
|
||||
<label for="workbuddy-model">{{ t('workbuddy.model') }}</label>
|
||||
<div class="workbuddy-model-actions">
|
||||
<select id="workbuddy-model" v-model="modelId" :disabled="loading || saving">
|
||||
<option value="">{{ t('workbuddy.native') }}</option>
|
||||
<option v-for="model in models" :key="model.id" :value="model.id">{{ model.name }}</option>
|
||||
<option v-if="modelId && !models.some(model => model.id === modelId)" :value="modelId">{{ t('workbuddy.modelUnavailable') }}</option>
|
||||
</select>
|
||||
<button class="secondary-button compact" type="submit" :disabled="loading || saving || modelId === savedModelId">{{ t('common.save') }}</button>
|
||||
<button class="secondary-button compact" type="button" :disabled="loading || saving" @click="load">{{ t('home.refresh') }}</button>
|
||||
</div>
|
||||
<p>{{ t('workbuddy.modelHint') }}</p>
|
||||
<p v-if="error" role="alert">{{ error }}</p>
|
||||
</form>
|
||||
</details>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { desktopApi } from '../desktop.js'
|
||||
import { t, translateError } from '../i18n.js'
|
||||
|
||||
const bridge = desktopApi()?.workBuddy
|
||||
const emit = defineEmits(['changed'])
|
||||
const models = ref([])
|
||||
const modelId = ref('')
|
||||
const savedModelId = ref('')
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
function apply(value) {
|
||||
models.value = value.models
|
||||
modelId.value = value.modelId
|
||||
savedModelId.value = value.modelId
|
||||
}
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try { apply(await bridge.settings()) } catch (failure) { error.value = translateError(failure) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
async function save() {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
try { apply(await bridge.setModel(modelId.value)); emit('changed') }
|
||||
catch (failure) { error.value = translateError(failure) }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
onMounted(() => { if (bridge) void load() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.workbuddy-settings { display: grid; gap: 8px; padding: 0 16px 12px; }
|
||||
.workbuddy-settings form { display: grid; gap: 8px; margin-top: 8px; }
|
||||
.workbuddy-settings summary { color: var(--muted); cursor: pointer; font-size: var(--text-xs); }
|
||||
.workbuddy-model-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
||||
.workbuddy-model-actions select { flex: 1; min-width: 160px; }
|
||||
.workbuddy-settings label { color: var(--text-soft); font-size: var(--text-xs); }
|
||||
.workbuddy-settings p { margin: 0; color: var(--muted); font-size: var(--text-xs); line-height: 1.5; }
|
||||
.workbuddy-settings [role="alert"] { color: var(--danger); }
|
||||
</style>
|
||||
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<section v-if="bridge" class="work-preferences" @keydown.left.stop @keydown.right.stop>
|
||||
<h2>{{ t('workPreferences.title') }}</h2>
|
||||
<p>{{ t('workPreferences.hint') }}</p>
|
||||
<div class="work-presets" :aria-label="t('workPreferences.presets')">
|
||||
<button v-for="preset in presets" :key="preset.id" class="work-preset" type="button" :aria-pressed="fields.every(field => form[field.id] === preset[field.id])" @click="applyPreset(preset)">
|
||||
{{ t(`workPreferences.preset.${preset.id}`) }}
|
||||
</button>
|
||||
</div>
|
||||
<form @submit.prevent="save">
|
||||
<div class="work-preference-controls">
|
||||
<h3>{{ t('workPreferences.deliverySection') }}</h3>
|
||||
<label v-for="field in fields" :key="field.id" class="work-preference-dial">
|
||||
<span>{{ t(`workPreferences.${field.id}`) }} <strong>{{ form[field.id] }}/100</strong></span>
|
||||
<input :value="form[field.id]" type="range" min="0" max="100" step="1" :style="{ '--preference-fill': `${form[field.id]}%` }" :aria-label="t(`workPreferences.${field.id}`)" :aria-valuetext="`${form[field.id]}/100`" @input="form[field.id] = Number($event.target.value)" />
|
||||
<small>{{ t(`workPreferences.${field.values[0]}`) }} <span>{{ t(`workPreferences.${field.values[2]}`) }}</span></small>
|
||||
</label>
|
||||
</div>
|
||||
<div class="work-preference-context">
|
||||
<fieldset v-if="agents.length">
|
||||
<legend>{{ t('workPreferences.agents') }}</legend>
|
||||
<div class="work-agent-options">
|
||||
<label v-for="agent in agents" :key="agent.kind" class="work-agent-choice">
|
||||
<input v-model="form.preferredAgentKinds" type="checkbox" :value="agent.kind" :disabled="!form.preferredAgentKinds.includes(agent.kind) && form.preferredAgentKinds.length >= 8" />
|
||||
<img :src="agentLogo(agent.sourceKind || agent.kind)" alt="" />
|
||||
{{ agent.label }}
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<label class="work-custom">{{ t('workPreferences.instructions') }}
|
||||
<textarea v-model="form.instructions" rows="2" maxlength="800" :placeholder="t('workPreferences.example')" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="work-preferences-footer">
|
||||
<output class="work-preferences-preview">{{ t('workPreferences.preview') }} · {{ t('workPreferences.detail') }} {{ form.detail }}/100 · {{ t('workPreferences.initiative') }} {{ form.initiative }}/100 · {{ t('workPreferences.evidence') }} {{ form.evidence }}/100</output>
|
||||
<div class="work-preferences-actions">
|
||||
<button class="primary-button compact" type="submit" :disabled="loading || saving">{{ t('common.save') }}</button>
|
||||
<button class="secondary-button compact" type="button" :disabled="loading || saving" @click="reset">{{ t('workPreferences.reset') }}</button>
|
||||
<span v-if="saved" role="status">{{ t('workPreferences.saved') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="error" role="alert">{{ error }}</p>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { desktopApi } from '../desktop.js'
|
||||
import { agentLabel, agentLogo } from '../catalog.js'
|
||||
import { t, translateError } from '../i18n.js'
|
||||
|
||||
const bridge = desktopApi()?.workPreferences
|
||||
const fields = [
|
||||
{ id: 'detail', values: ['concise', 'balanced', 'thorough'] },
|
||||
{ id: 'initiative', values: ['ask', 'balanced', 'proactive'] },
|
||||
{ id: 'evidence', values: ['practical', 'balanced', 'strict'] },
|
||||
]
|
||||
const presets = [
|
||||
{ id: 'quick', detail: 15, initiative: 85, evidence: 50 },
|
||||
{ id: 'careful', detail: 90, initiative: 15, evidence: 95 },
|
||||
{ id: 'explore', detail: 80, initiative: 90, evidence: 55 },
|
||||
{ id: 'decision', detail: 25, initiative: 50, evidence: 90 },
|
||||
{ id: 'practical', detail: 45, initiative: 85, evidence: 30 },
|
||||
]
|
||||
const form = reactive({ detail: 50, initiative: 50, evidence: 50, preferredAgentKinds: [], instructions: '' })
|
||||
const agents = ref([])
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const saved = ref(false)
|
||||
const error = ref('')
|
||||
let unsubscribe
|
||||
watch(form, () => { saved.value = false }, { flush: 'sync' })
|
||||
function updateAgents(snapshot) {
|
||||
agents.value = (snapshot?.agents || []).filter(agent => agent.installed !== false)
|
||||
.map(agent => ({ kind: agent.kind, sourceKind: agent.sourceKind, label: agent.label || agent.name || agentLabel(agent.kind) }))
|
||||
}
|
||||
function apply(value) {
|
||||
for (const field of fields) form[field.id] = typeof value[field.id] === 'number' ? value[field.id] : Math.max(0, field.values.indexOf(value[field.id])) * 50
|
||||
form.preferredAgentKinds = [...value.preferredAgentKinds]
|
||||
form.instructions = value.instructions
|
||||
}
|
||||
function reset() {
|
||||
Object.assign(form, { detail: 50, initiative: 50, evidence: 50, preferredAgentKinds: [], instructions: '' })
|
||||
saved.value = false
|
||||
}
|
||||
function applyPreset(preset) {
|
||||
for (const field of fields) form[field.id] = preset[field.id]
|
||||
saved.value = false
|
||||
}
|
||||
async function save() {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
saved.value = false
|
||||
try {
|
||||
const value = { preferredAgentKinds: [...form.preferredAgentKinds], instructions: form.instructions }
|
||||
for (const field of fields) value[field.id] = form[field.id]
|
||||
apply(await bridge.save(value))
|
||||
saved.value = true
|
||||
} catch (failure) { error.value = translateError(failure) }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
onMounted(async () => {
|
||||
if (!bridge) return
|
||||
loading.value = true
|
||||
unsubscribe = desktopApi().localWorkspace?.onChanged?.(updateAgents)
|
||||
try {
|
||||
apply(await bridge.get())
|
||||
updateAgents(await desktopApi().localWorkspace?.get())
|
||||
} catch (failure) { error.value = translateError(failure) }
|
||||
finally { loading.value = false }
|
||||
})
|
||||
onBeforeUnmount(() => unsubscribe?.())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.work-preferences { display: grid; gap: 16px; min-width: 0; container-name: work-preferences; container-type: inline-size; }
|
||||
.work-preferences h2 { margin: 0; color: var(--text); font-size: var(--text-lg); }
|
||||
.work-preferences p { margin: 0; font-size: var(--text-xs); color: var(--muted); line-height: 1.6; }
|
||||
.work-preferences form { display: grid; gap: 28px; min-width: 0; }
|
||||
.work-preference-controls, .work-preference-context { display: grid; align-content: start; gap: 26px; min-width: 0; }
|
||||
.work-preference-controls h3 { margin: 0; color: var(--text); font-size: var(--text-sm); font-weight: 600; }
|
||||
.work-preferences-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.work-preferences-actions { flex-shrink: 0; }
|
||||
.work-agent-options { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 190px), 1fr)); gap: 10px 16px; }
|
||||
.work-presets, .work-preferences-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
.work-preset { border: 0; box-shadow: none; border-radius: var(--radius-sm); padding: 8px 10px; background: transparent; color: var(--text-soft); font: inherit; font-size: var(--text-xs); cursor: pointer; }
|
||||
.work-preset:hover, .work-preset[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent-text); }
|
||||
.work-preference-dial, .work-custom { display: grid; gap: 8px; font-size: var(--text-sm); }
|
||||
.work-preference-dial > span { display: flex; justify-content: space-between; gap: 10px; }
|
||||
.work-preference-dial strong { color: var(--accent-text); font-weight: 600; }
|
||||
.work-preference-dial input { width: 100%; height: 26px; margin: 0; padding: 0; appearance: none; background: transparent; border: 0; box-shadow: none; cursor: ew-resize; -webkit-app-region: no-drag; touch-action: pan-y; }
|
||||
.work-preference-dial input::-webkit-slider-runnable-track { height: 6px; border: 0; border-radius: 3px; box-shadow: none; background: linear-gradient(to right, var(--accent) var(--preference-fill), var(--surface-hover) var(--preference-fill)); }
|
||||
.work-preference-dial input::-webkit-slider-thumb { appearance: none; width: 16px; height: 16px; margin-top: -5px; border: 0; border-radius: 50%; box-shadow: none; background: var(--accent); }
|
||||
.work-preference-dial input::-moz-range-track { height: 6px; border: 0; border-radius: 3px; background: var(--surface-hover); }
|
||||
.work-preference-dial input::-moz-range-progress { height: 6px; border: 0; border-radius: 3px; background: var(--accent); }
|
||||
.work-preference-dial input::-moz-range-thumb { width: 16px; height: 16px; border: 0; border-radius: 50%; background: var(--accent); }
|
||||
.work-agent-choice img { width: 18px; height: 18px; object-fit: contain; flex-shrink: 0; }
|
||||
.work-custom textarea { width: 100%; box-sizing: border-box; padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); color: var(--text); font: inherit; resize: vertical; }
|
||||
.work-preference-dial small { display: flex; justify-content: space-between; color: var(--muted); }
|
||||
fieldset { border: 0; padding: 0; margin: 0; }
|
||||
legend { margin-bottom: 16px; font-size: var(--text-sm); font-weight: 600; }
|
||||
.work-agent-choice { display: flex; align-items: center; gap: 8px; min-height: 28px; font-size: var(--text-xs); overflow-wrap: anywhere; cursor: pointer; }
|
||||
.work-agent-choice input { width: auto; accent-color: var(--accent); }
|
||||
.work-preferences-preview { flex: 1 1 280px; color: var(--muted); font-size: var(--text-xs); line-height: 1.7; }
|
||||
@container work-preferences (min-width: 900px) {
|
||||
.work-preferences form { grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr); column-gap: clamp(32px, 5cqw, 72px); }
|
||||
.work-preferences-footer, .work-preferences form > [role="alert"] { grid-column: 1 / -1; }
|
||||
}
|
||||
[role="alert"] { color: var(--danger) !important; }
|
||||
[role="status"] { color: var(--muted); font-size: var(--text-xs); }
|
||||
</style>
|
||||
@@ -96,6 +96,7 @@ export function useAgentCatalog({
|
||||
const readyCount = computed(() => readyAgents.value.length)
|
||||
const installedCount = computed(() => mergedCatalog.value.filter(agent => agent.installed).length)
|
||||
const sidebarAgents = computed(() => mergedCatalog.value.filter((agent) => {
|
||||
if (agent.showInSidebar === false || (agent.kind === 'workbuddy' && !agent.ready)) return false
|
||||
if ((agent.ready || agent.installed) && agent.showInSidebar !== false) return true
|
||||
return !agent.ready && directGroupsFor(agent.kind).length > 0
|
||||
}))
|
||||
@@ -138,6 +139,9 @@ export function useAgentCatalog({
|
||||
}
|
||||
|
||||
function agentState(agent) {
|
||||
if (['workbuddy-desktop-locked', 'workbuddy-model-unavailable'].includes(agent.availabilitySource)) {
|
||||
return { label: t('workbuddy.configure'), tone: 'warning', icon: WarningOutline }
|
||||
}
|
||||
if (agent.ready) return { label: t('agent.ready'), tone: 'ready', icon: CheckmarkCircleOutline }
|
||||
if (agent.custom) return { label: t('customAgent.executableUnavailable'), tone: 'warning', icon: WarningOutline }
|
||||
if (!agent.installed) return { label: t('agent.notInstalled'), tone: 'off', icon: DownloadOutline }
|
||||
@@ -160,6 +164,8 @@ export function useAgentCatalog({
|
||||
// next. Empty when there is nothing to explain (ready, or not installed).
|
||||
function agentStateNote(agent) {
|
||||
if (!agent || agent.ready || agent.custom || !agent.installed) return ''
|
||||
if (agent.availabilitySource === 'workbuddy-desktop-locked') return t('workbuddy.desktopLocked')
|
||||
if (agent.availabilitySource === 'workbuddy-model-unavailable') return t('workbuddy.modelUnavailable')
|
||||
const version = agent.resolvedVersion || agent.version || ''
|
||||
const params = { version: version || t('agent.versionUnknown') }
|
||||
const verified = agent.supportedVersionRange
|
||||
|
||||
@@ -253,6 +253,8 @@ export function useComposerContext({
|
||||
// was typed. Attachments and mentions are still reset: they are tied to the
|
||||
// conversation's members and to files staged for it.
|
||||
const draftsByGroupId = new Map()
|
||||
const modesByGroupId = new Map()
|
||||
let contextGroupId = ''
|
||||
|
||||
function stashComposerDraft(groupId) {
|
||||
const id = String(groupId || '')
|
||||
@@ -269,10 +271,14 @@ export function useComposerContext({
|
||||
function pruneComposerDrafts(groupIds) {
|
||||
const live = new Set((Array.isArray(groupIds) ? groupIds : []).map(String))
|
||||
for (const id of draftsByGroupId.keys()) if (!live.has(id)) draftsByGroupId.delete(id)
|
||||
for (const id of modesByGroupId.keys()) if (!live.has(id)) modesByGroupId.delete(id)
|
||||
}
|
||||
|
||||
function resetComposerContext(group) {
|
||||
discussionMode.value = group?.conversationType === 'direct' ? 'manual' : 'auto'
|
||||
if (contextGroupId) modesByGroupId.set(contextGroupId, discussionMode.value)
|
||||
contextGroupId = String(group?.id || '')
|
||||
discussionMode.value = group?.conversationType === 'direct'
|
||||
? 'manual' : modesByGroupId.get(contextGroupId) || 'auto'
|
||||
automaticTeamFormation.value = false
|
||||
targetKinds.value = group?.conversationType === 'direct' ? [...group.agentKinds] : []
|
||||
clearComposerContext()
|
||||
|
||||
@@ -39,6 +39,11 @@ export function useOnboarding({ refreshAgents }) {
|
||||
title: t('onboarding.autoTitle'),
|
||||
body: t('onboarding.autoBody'),
|
||||
},
|
||||
{
|
||||
preferences: true,
|
||||
title: t('workPreferences.onboardingTitle'),
|
||||
body: t('workPreferences.onboardingHint'),
|
||||
},
|
||||
])
|
||||
const onboardingSlide = computed(() => (
|
||||
onboardingSlides.value[onboardingIndex.value] || onboardingSlides.value[0]
|
||||
|
||||
@@ -347,6 +347,7 @@ export function normalizeSnapshot(value) {
|
||||
return {
|
||||
agents: Array.isArray(value?.agents) ? value.agents : [],
|
||||
groups: Array.isArray(value?.groups) ? value.groups : [],
|
||||
deliveryTasks: Array.isArray(value?.deliveryTasks) ? value.deliveryTasks : [],
|
||||
messages,
|
||||
runningGroupIds: Array.isArray(value?.runningGroupIds) ? value.runningGroupIds : [],
|
||||
runs,
|
||||
|
||||
@@ -49,6 +49,13 @@ export function messageKeys(language) {
|
||||
}
|
||||
|
||||
export const DESKTOP_ERROR_MESSAGE_KEYS = Object.freeze({
|
||||
LOCAL_DELIVERY_INVALID: 'delivery.error.invalid',
|
||||
LOCAL_DELIVERY_NOT_FOUND: 'delivery.error.notFound',
|
||||
LOCAL_DELIVERY_LOCKED: 'delivery.error.locked',
|
||||
LOCAL_DELIVERY_REVISION_REQUIRED: 'delivery.error.revision',
|
||||
LOCAL_DELIVERY_NOT_READY: 'delivery.error.notReady',
|
||||
LOCAL_DELIVERY_INPUT_LIMIT: 'delivery.error.limit',
|
||||
LOCAL_DELIVERY_EXPORT_FAILED: 'delivery.error.export',
|
||||
LOCAL_WORKSPACE_UNAVAILABLE: 'error.workspaceUnavailable',
|
||||
LOCAL_GROUP_AGENT_REQUIRED: 'error.groupAgentRequired',
|
||||
LOCAL_GROUP_NOT_FOUND: 'error.groupNotFound',
|
||||
@@ -90,9 +97,15 @@ export const DESKTOP_ERROR_MESSAGE_KEYS = Object.freeze({
|
||||
CODEX_SANDBOX_UNSUPPORTED: 'error.codexSandboxUnsupported',
|
||||
LOCAL_AGENT_KIND_UNSUPPORTED: 'error.agentKindUnsupported',
|
||||
LOCAL_AGENT_AUTH_REQUIRED: 'error.agentAuthRequired',
|
||||
LOCAL_WORKBUDDY_MODEL_UNAVAILABLE: 'workbuddy.modelUnavailable',
|
||||
LOCAL_WORKBUDDY_BUSY: 'workbuddy.busy',
|
||||
LOCAL_WORKBUDDY_PROVIDER_AUTH: 'workbuddy.providerAuth',
|
||||
LOCAL_WORK_PREFERENCES_INVALID: 'workPreferences.invalid',
|
||||
LOCAL_WORK_PREFERENCES_BUSY: 'workPreferences.busy',
|
||||
LOCAL_AGENT_ACCESS_DENIED: 'error.agentAccessDenied',
|
||||
LOCAL_AGENT_SESSION_INVALID: 'error.agentSessionInvalid',
|
||||
LOCAL_AGENT_OUTPUT_LIMIT: 'error.agentOutputLimit',
|
||||
LOCAL_AGENT_REPEATED_OUTPUT: 'error.agentRepeatedOutput',
|
||||
LOCAL_AGENT_SEND_FAILED: 'error.agentSendFailed',
|
||||
LOCAL_AGENT_REFUSED: 'error.agentRefused',
|
||||
LOCAL_AGENT_PERMISSION_TERMINAL_RACE: 'error.agentPermissionRace',
|
||||
|
||||
+127
-10
@@ -1,16 +1,130 @@
|
||||
export default {
|
||||
'workPreferences.deliverySection': 'Delivery approach',
|
||||
'workPreferences.title': 'How do you like work delivered?',
|
||||
'workPreferences.hint': 'Tune your defaults. Each task still takes priority, and permissions stay unchanged.',
|
||||
'workPreferences.onboardingTitle': 'Find your working rhythm',
|
||||
'workPreferences.onboardingHint': 'Pick a starting style or move the dials. Save when ready, or skip and adjust later in General settings.',
|
||||
'workPreferences.presets': 'Starting styles',
|
||||
'workPreferences.preset.quick': '⚡ Move things forward',
|
||||
'workPreferences.preset.careful': '🔎 Check every claim',
|
||||
'workPreferences.preset.explore': '✦ Explore together',
|
||||
'workPreferences.preset.decision': '◈ Decision support',
|
||||
'workPreferences.preset.practical': '↗ Practical delivery',
|
||||
'workPreferences.detail': 'Delivery detail',
|
||||
'workPreferences.initiative': 'Working approach',
|
||||
'workPreferences.evidence': 'Evidence standard',
|
||||
'workPreferences.concise': 'Key points',
|
||||
'workPreferences.balanced': 'Balanced',
|
||||
'workPreferences.thorough': 'Full explanation',
|
||||
'workPreferences.ask': 'Align first',
|
||||
'workPreferences.proactive': 'Advance reversible work',
|
||||
'workPreferences.practical': 'Actionable first',
|
||||
'workPreferences.strict': 'Verify sources',
|
||||
'workPreferences.agents': 'Preferred collaborators (optional, up to 8)',
|
||||
'workPreferences.instructions': 'Anything else? (optional)',
|
||||
'workPreferences.example': 'For example: start with conclusions, include a comparison table, and flag missing data.',
|
||||
'workPreferences.preview': 'Your delivery style',
|
||||
'workPreferences.reset': 'Reset dials',
|
||||
'workPreferences.saved': 'Saved for future turns',
|
||||
'workPreferences.invalid': 'Check the preference values and keep your note within 800 characters.',
|
||||
'workPreferences.busy': 'Wait for current runs to finish before saving preferences.',
|
||||
'workbuddy.advanced': 'Advanced CLI compatibility',
|
||||
'workbuddy.configure': 'CLI setup needed',
|
||||
'workbuddy.providerAuth': 'The selected WorkBuddy model rejected its credentials or endpoint. Check that model’s API key and URL in WorkBuddy, then retry.',
|
||||
'workbuddy.desktopLocked': 'WorkBuddy desktop is signed in using host-encrypted credentials that its standalone CLI cannot unlock. Select a configured local model below or configure separate CLI credentials.',
|
||||
'workbuddy.model': 'WorkBuddy CLI model',
|
||||
'workbuddy.native': 'Native CLI account / environment',
|
||||
'workbuddy.modelHint': 'Lists tool-capable models with credentials in your WorkBuddy configuration. Only the model choice is saved in Meldwork. Desktop sign-in alone may not authorize the standalone CLI.',
|
||||
'workbuddy.modelUnavailable': 'The selected WorkBuddy model is unavailable. Check its native configuration and select again.',
|
||||
'workbuddy.busy': 'Wait for current runs to finish before changing the WorkBuddy model.',
|
||||
'delivery.unsavedReview': 'Review changes are not saved. Save the review or accept before exporting text.',
|
||||
'delivery.versionRun': 'Run for this version',
|
||||
'delivery.revisionFiles': 'Revisions include this version’s actual files. Add new materials in the composer.',
|
||||
'delivery.run.round-limit': 'Round limit reached',
|
||||
'delivery.run.budget-exhausted': 'Budget exhausted',
|
||||
'delivery.run.circuit-breaker': 'Stopped by run protection',
|
||||
'delivery.viewing': 'Viewing delivery; next message is regular chat',
|
||||
'agentDiscovery.kicker': 'Local workspace startup',
|
||||
'agentDiscovery.title': 'Finding your local Agents',
|
||||
'agentDiscovery.body': 'Meldwork is checking the Agent CLIs available on this computer.',
|
||||
'agentDiscovery.status': 'Preparing your workspace',
|
||||
"delivery.title": "Tasks & delivery",
|
||||
"delivery.bound": "Next message runs this task",
|
||||
"delivery.select": "Select task",
|
||||
"delivery.chat": "Regular chat",
|
||||
"delivery.new": "New task",
|
||||
"delivery.edit": "Edit task",
|
||||
"delivery.reuse": "Do again",
|
||||
"delivery.sample": "Use project brief example",
|
||||
"delivery.name": "Task name",
|
||||
"delivery.goal": "Goal",
|
||||
"delivery.output": "Deliverable requirements",
|
||||
"delivery.kind": "Deliverable type",
|
||||
"delivery.text": "Text",
|
||||
"delivery.files": "Actual files",
|
||||
"delivery.criteria": "Acceptance criteria (one per line, up to 20)",
|
||||
"delivery.sendHint": "Select materials and Agents in the composer, then send to start.",
|
||||
"delivery.revision": "Revision instructions",
|
||||
"delivery.run": "Run status",
|
||||
"delivery.none": "No deliverable yet",
|
||||
"delivery.version": "Delivery version",
|
||||
"delivery.status": "Delivery status",
|
||||
"delivery.accepted": "Accepted",
|
||||
"delivery.changes": "Changes requested",
|
||||
"delivery.pending": "Awaiting review",
|
||||
"delivery.openFile": "Open",
|
||||
"delivery.exportFile": "Export file",
|
||||
"delivery.missingFile": "This version has no actual file and cannot be accepted as a file deliverable.",
|
||||
"delivery.sources": "Inputs for this run",
|
||||
"delivery.sourceHint": "This lists input scope; conversation history may also provide context. Verify citations; this list does not substantiate every claim.",
|
||||
"delivery.noSources": "No attachments or knowledge sources selected; verify the basis for the conclusions.",
|
||||
"delivery.review": "Human review",
|
||||
"delivery.check.pending": "Not checked",
|
||||
"delivery.check.met": "Met",
|
||||
"delivery.check.unmet": "Not met",
|
||||
"delivery.check.unknown": "Unknown",
|
||||
"delivery.notes": "Notes and unresolved issues",
|
||||
"delivery.saveReview": "Save review",
|
||||
"delivery.accept": "Accept this version",
|
||||
"delivery.export": "Export Markdown",
|
||||
"delivery.copy": "Copy text",
|
||||
"delivery.requestRevision": "Save revision request",
|
||||
"delivery.empty": "Create a task to track deliverables, or continue chatting.",
|
||||
"delivery.exported": "Exported",
|
||||
"delivery.copied": "Text copied",
|
||||
"delivery.reused": "Created a separate read-only conversation. Select fresh materials.",
|
||||
"delivery.sampleTitle": "Project progress brief",
|
||||
"delivery.sampleGoal": "Brief the project owner on current progress, risks, and decisions needed.",
|
||||
"delivery.sampleOutput": "A brief covering key metrics, delays, next steps, and source references.",
|
||||
"delivery.sampleCriteria": "Includes progress, risks, and next steps\nKey figures include source references\nMissing or conflicting information is listed",
|
||||
"delivery.error.invalid": "Check task fields, criteria count, and text length.",
|
||||
"delivery.error.notFound": "Task or version not found. Refresh and retry.",
|
||||
"delivery.error.locked": "Task requirements or accepted versions are locked.",
|
||||
"delivery.error.revision": "Select a version and save revision instructions first.",
|
||||
"delivery.error.notReady": "Complete every review item and ensure required files exist.",
|
||||
"delivery.error.limit": "Task and previous version exceed the input limit. Narrow the scope.",
|
||||
"delivery.error.export": "Export failed. Choose a writable location and retry.",
|
||||
"delivery.run.preparing": "Preparing",
|
||||
"delivery.run.running": "Running",
|
||||
"delivery.run.waiting": "Waiting for input or approval",
|
||||
"delivery.run.completed": "Run completed",
|
||||
"delivery.run.partial": "Partially completed",
|
||||
"delivery.run.failed": "Run failed",
|
||||
"delivery.run.stopped": "Stopped",
|
||||
"delivery.run.timeout": "Timed out",
|
||||
"delivery.run.interrupted": "Interrupted",
|
||||
'app.localWorkspace': 'Multi-Agent workspace',
|
||||
'app.desktopRequired': 'Open Meldwork in the desktop app.',
|
||||
'app.desktopRequiredDetail': 'The local Agent bridge is unavailable in this window.',
|
||||
'app.loading': 'Reading local Agents',
|
||||
'onboarding.discoverTitle': 'Bring your local Agents together',
|
||||
'onboarding.discoverBody': 'Meldwork finds the supported Agent CLIs already available on this computer.',
|
||||
'onboarding.collaborationTitle': 'Share one task across Agents',
|
||||
'onboarding.collaborationBody': 'Keep different Agents in one workspace so they can contribute, challenge, and continue the work.',
|
||||
'onboarding.discoverTitle': 'Bring your existing Agents into a working group',
|
||||
'onboarding.discoverBody': 'Meldwork detects supported Agent CLIs on this computer. You choose who works on the task together.',
|
||||
'onboarding.collaborationTitle': 'Set the goal. Let Agents work together.',
|
||||
'onboarding.collaborationBody': 'Share context, discuss approaches, and check results with less relaying and stitching outputs together.',
|
||||
'onboarding.toolsTitle': 'Add the context the work needs',
|
||||
'onboarding.toolsBody': 'Reference local skills, images, and an approved workspace without leaving the conversation.',
|
||||
'onboarding.autoTitle': 'Let the group discuss until it settles',
|
||||
'onboarding.autoBody': 'Automatic discussion keeps Agents relaying context and refining the answer across rounds.',
|
||||
'onboarding.autoTitle': 'Let Agents advance the task together',
|
||||
'onboarding.autoBody': 'Set the goal and permissions, then let Agents build on each other\'s work in Auto Discussion. Step in when they need information, permission, or a key decision.',
|
||||
'onboarding.previous': 'Previous slide',
|
||||
'onboarding.next': 'Next slide',
|
||||
'onboarding.progress': 'Onboarding progress',
|
||||
@@ -118,6 +232,8 @@ export default {
|
||||
'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 project assistance for understanding context, planning changes, and moving work forward.',
|
||||
'agent.description.zcode': 'Local task execution using Zcode CLI configuration and resumable sessions.',
|
||||
'agent.soul.zcode': 'Zcode uses its native model configuration to analyze tasks and work in the local workspace.',
|
||||
'agent.description.pi': 'Focused local task execution through Pi Agent native configuration.',
|
||||
'agent.description.kimi': 'Long-context reading, dense document analysis, planning, and complex workspace tasks.',
|
||||
'agent.description.mimo': 'Focused local execution for planning, analysis, implementation, and rapid iteration.',
|
||||
@@ -149,8 +265,8 @@ export default {
|
||||
'conversation.renameTitle': 'Rename conversation',
|
||||
'conversation.emptyShowcase.0.title': 'Start with one Agent when you want to move quickly',
|
||||
'conversation.emptyShowcase.0.body': 'Type your task below and keep the conversation local, focused, and easy to continue',
|
||||
'conversation.emptyShowcase.1.title': 'Bring in more Agents when a second view would help',
|
||||
'conversation.emptyShowcase.1.body': 'Start a group, @ the Agents you need, then let them compare and build on the same context',
|
||||
'conversation.emptyShowcase.1.title': 'Set the goal. Let Agents work together.',
|
||||
'conversation.emptyShowcase.1.body': 'Start a group, select Agents, and enable Auto Discussion so they can advance the work and check results. Step in when a decision needs you',
|
||||
'conversation.emptyShowcase.2.title': 'Add the right skills and knowledge before they begin',
|
||||
'conversation.emptyShowcase.2.body': 'After @ mentioning an Agent, @ a skill or knowledge base to give them the right context',
|
||||
'conversation.you': 'You',
|
||||
@@ -401,7 +517,7 @@ export default {
|
||||
'composer.auto': 'Sequential discussion',
|
||||
'composer.sequentialHint': 'Agents reply one at a time, in turn — each may build on the previous answer, up to the round limit.',
|
||||
'composer.autoBeta': 'Auto discussion',
|
||||
'composer.autoBetaHint': 'Beta: Agents @ each other into the discussion and the model orchestrates the rounds autonomously.',
|
||||
'composer.autoBetaHint': 'Beta: Agents request peer contributions, advance the work, and check results. Step in when they need information, permission, or a key decision.',
|
||||
'composer.autoBetaBadge': 'Beta',
|
||||
'composer.smartTeam': 'Smart team',
|
||||
'composer.smartTeamOn': 'On',
|
||||
@@ -659,7 +775,7 @@ export default {
|
||||
'agentDetail.localSkills': 'Local skills',
|
||||
'agentDetail.remoteSkills': 'Remote skills',
|
||||
'agentDetail.showInSidebar': 'Show in sidebar',
|
||||
'agentDetail.showInSidebarHint': 'Keep this ready Agent in the left navigation.',
|
||||
'agentDetail.showInSidebarHint': 'Hiding keeps conversation history. Unavailable WorkBuddy is automatically removed from the sidebar.',
|
||||
'shortcut.title': 'Keyboard shortcuts',
|
||||
'shortcut.toggleSidebar': 'Toggle sidebar',
|
||||
'shortcut.newGroup': 'Create group chat',
|
||||
@@ -780,6 +896,7 @@ export default {
|
||||
'error.runCircuitBreaker': 'This run stopped after too many failed attempts.',
|
||||
'error.agentAuthRequired': 'Sign in to this Agent or configure its credentials, then try again.',
|
||||
'error.agentProcessFailed': 'The Agent process failed before completing the task.',
|
||||
'error.agentRepeatedOutput': 'The Agent kept repeating the same response. This call was stopped and received output was preserved. Check the CLI model/provider configuration before trying again.',
|
||||
'error.customAgentLimit': 'You can connect up to 32 custom Agents.',
|
||||
'error.customAgentLabel': 'Enter a valid Agent name.',
|
||||
'error.customAgentDescription': 'Keep the description within 240 characters.',
|
||||
|
||||
+127
-10
@@ -1,16 +1,130 @@
|
||||
export default {
|
||||
'workPreferences.deliverySection': '交付方式',
|
||||
'workPreferences.title': '你喜欢怎样的工作交付?',
|
||||
'workPreferences.hint': '调好你的默认偏好。具体任务要求优先,执行权限保持不变。',
|
||||
'workPreferences.onboardingTitle': '找到你的工作节奏',
|
||||
'workPreferences.onboardingHint': '选个起点,或拨动滑杆。满意后保存;也可以跳过,稍后在通用设置中调整。',
|
||||
'workPreferences.presets': '工作风格起点',
|
||||
'workPreferences.preset.quick': '⚡ 先推动起来',
|
||||
'workPreferences.preset.careful': '🔎 每条都核实',
|
||||
'workPreferences.preset.explore': '✦ 一起探索',
|
||||
'workPreferences.preset.decision': '◈ 辅助决策',
|
||||
'workPreferences.preset.practical': '↗ 务实落地',
|
||||
'workPreferences.detail': '交付详略',
|
||||
'workPreferences.initiative': '推进方式',
|
||||
'workPreferences.evidence': '证据要求',
|
||||
'workPreferences.concise': '只看要点',
|
||||
'workPreferences.balanced': '适中',
|
||||
'workPreferences.thorough': '充分说明',
|
||||
'workPreferences.ask': '先对齐再推进',
|
||||
'workPreferences.proactive': '主动推进可逆工作',
|
||||
'workPreferences.practical': '行动建议优先',
|
||||
'workPreferences.strict': '核实来源',
|
||||
'workPreferences.agents': '偏好的协作者(可选,最多 8 个)',
|
||||
'workPreferences.instructions': '还有什么习惯?(可选)',
|
||||
'workPreferences.example': '例如:先给结论,用表格比较方案,缺少的数据单独列出。',
|
||||
'workPreferences.preview': '你的交付风格',
|
||||
'workPreferences.reset': '重置滑杆',
|
||||
'workPreferences.saved': '已保存,将用于后续对话',
|
||||
'workPreferences.invalid': '请检查偏好选项,补充说明不要超过 800 字。',
|
||||
'workPreferences.busy': '请等待当前运行结束,再保存偏好。',
|
||||
'workbuddy.advanced': '高级 CLI 兼容设置',
|
||||
'workbuddy.configure': '需要配置 CLI',
|
||||
'workbuddy.providerAuth': '所选 WorkBuddy 模型的凭据或服务地址被拒绝,请在 WorkBuddy 中检查该模型的 API Key 和 URL 后重试。',
|
||||
'workbuddy.desktopLocked': 'WorkBuddy 客户端的登录凭据由客户端加密,独立 CLI 无法解锁。请在下方选择已配置的本地模型,或单独配置 CLI 凭据。',
|
||||
'workbuddy.model': 'WorkBuddy CLI 模型',
|
||||
'workbuddy.native': '沿用 CLI 账号或环境配置',
|
||||
'workbuddy.modelHint': '列出 WorkBuddy 原生配置中支持工具且已有密钥的模型。Meldwork 仅保存模型选择;客户端已登录不代表独立 CLI 已获授权。',
|
||||
'workbuddy.modelUnavailable': '所选 WorkBuddy 模型不可用,请检查其原生配置并重新选择。',
|
||||
'workbuddy.busy': '请等待当前运行结束,再更改 WorkBuddy 模型。',
|
||||
'delivery.unsavedReview': '检查结果尚未保存。导出正文前请保存检查或确认采用。',
|
||||
'delivery.versionRun': '此版本的运行',
|
||||
'delivery.revisionFiles': '修订时会附带此版本的实际文件;可在输入框补充新材料。',
|
||||
'delivery.run.round-limit': '达到轮次上限',
|
||||
'delivery.run.budget-exhausted': '预算已用尽',
|
||||
'delivery.run.circuit-breaker': '运行保护已停止执行',
|
||||
'delivery.viewing': '查看交付中;下一次发送为普通聊天',
|
||||
'agentDiscovery.kicker': '本地工作台启动中',
|
||||
'agentDiscovery.title': '正在寻找本地 Agent',
|
||||
'agentDiscovery.body': 'Meldwork 正在检查这台电脑上可用的 Agent CLI。',
|
||||
'agentDiscovery.status': '正在准备工作台',
|
||||
"delivery.title": "任务与交付",
|
||||
"delivery.bound": "下一次发送用于此任务",
|
||||
"delivery.select": "选择任务",
|
||||
"delivery.chat": "普通聊天",
|
||||
"delivery.new": "新建任务",
|
||||
"delivery.edit": "编辑任务",
|
||||
"delivery.reuse": "再做一次",
|
||||
"delivery.sample": "使用项目简报示例",
|
||||
"delivery.name": "任务名称",
|
||||
"delivery.goal": "工作目标",
|
||||
"delivery.output": "交付要求",
|
||||
"delivery.kind": "交付类型",
|
||||
"delivery.text": "正文",
|
||||
"delivery.files": "实际文件",
|
||||
"delivery.criteria": "验收标准(每行一项,最多 20 项)",
|
||||
"delivery.sendHint": "请在原输入框选择本次材料和 Agent,然后发送以开始。",
|
||||
"delivery.revision": "修改意见",
|
||||
"delivery.run": "运行状态",
|
||||
"delivery.none": "尚无交付成果",
|
||||
"delivery.version": "交付版本",
|
||||
"delivery.status": "交付状态",
|
||||
"delivery.accepted": "已采用",
|
||||
"delivery.changes": "要求修改",
|
||||
"delivery.pending": "待验收",
|
||||
"delivery.openFile": "打开",
|
||||
"delivery.exportFile": "导出文件",
|
||||
"delivery.missingFile": "此版本没有实际文件,暂不能按文件任务验收。",
|
||||
"delivery.sources": "本次输入材料",
|
||||
"delivery.sourceHint": "这是输入范围清单,会话历史也可能参与上下文。请核对正文引用;清单不代表每条结论已得到证实。",
|
||||
"delivery.noSources": "没有选择附件或知识源;请核实结论依据。",
|
||||
"delivery.review": "人工验收",
|
||||
"delivery.check.pending": "待检查",
|
||||
"delivery.check.met": "符合",
|
||||
"delivery.check.unmet": "不符合",
|
||||
"delivery.check.unknown": "无法判断",
|
||||
"delivery.notes": "备注与未决事项",
|
||||
"delivery.saveReview": "保存检查",
|
||||
"delivery.accept": "确认采用此版本",
|
||||
"delivery.export": "导出 Markdown",
|
||||
"delivery.copy": "复制正文",
|
||||
"delivery.requestRevision": "保存修改要求",
|
||||
"delivery.empty": "新建任务以记录交付要求;也可以继续普通聊天。",
|
||||
"delivery.exported": "已导出",
|
||||
"delivery.copied": "正文已复制",
|
||||
"delivery.reused": "已创建独立只读会话,请重新选择本次材料。",
|
||||
"delivery.sampleTitle": "项目进展简报",
|
||||
"delivery.sampleGoal": "向负责人说明本期项目进展、风险和需要决定的事项。",
|
||||
"delivery.sampleOutput": "一份简报,包含关键指标、延期事项、下一步及来源说明。",
|
||||
"delivery.sampleCriteria": "包含进展、风险和下一步\n关键数字注明来源\n缺失或冲突信息明确列出",
|
||||
"delivery.error.invalid": "请检查任务字段、验收标准数量及内容长度。",
|
||||
"delivery.error.notFound": "任务或交付版本不存在,请刷新后重试。",
|
||||
"delivery.error.locked": "任务要求或已采用版本不可修改。",
|
||||
"delivery.error.revision": "请先选择一个版本并保存修改要求。",
|
||||
"delivery.error.notReady": "请完成全部验收项,并确认要求的文件已生成。",
|
||||
"delivery.error.limit": "任务与历史版本内容过长,请缩小任务范围。",
|
||||
"delivery.error.export": "导出失败,请选择可写的位置重试。",
|
||||
"delivery.run.preparing": "准备中",
|
||||
"delivery.run.running": "运行中",
|
||||
"delivery.run.waiting": "等待输入或审批",
|
||||
"delivery.run.completed": "运行完成",
|
||||
"delivery.run.partial": "部分完成",
|
||||
"delivery.run.failed": "运行失败",
|
||||
"delivery.run.stopped": "已停止",
|
||||
"delivery.run.timeout": "已超时",
|
||||
"delivery.run.interrupted": "已中断",
|
||||
'app.localWorkspace': '多 Agent 协作工作台',
|
||||
'app.desktopRequired': '请在 Meldwork 桌面端打开。',
|
||||
'app.desktopRequiredDetail': '当前窗口无法访问本地 Agent 桥接。',
|
||||
'app.loading': '正在读取本地 Agent',
|
||||
'onboarding.discoverTitle': '把本地 Agent 汇聚到一起',
|
||||
'onboarding.discoverBody': 'Meldwork 会检测这台电脑上已经可用的受支持 Agent CLI。',
|
||||
'onboarding.collaborationTitle': '让多个 Agent 共享同一任务',
|
||||
'onboarding.collaborationBody': '不同 Agent 在同一个工作台中补充、质疑并继续彼此的工作。',
|
||||
'onboarding.discoverTitle': '把已有的 Agent 组织成工作小组',
|
||||
'onboarding.discoverBody': 'Meldwork 检测这台电脑上受支持的 Agent CLI,由你选择一起处理任务的成员。',
|
||||
'onboarding.collaborationTitle': '提出目标,让 Agent 们协作完成',
|
||||
'onboarding.collaborationBody': '共享上下文、讨论方案并相互检查,减少你在 Agent 之间转述和整合结果的工作。',
|
||||
'onboarding.toolsTitle': '补齐完成任务所需的上下文',
|
||||
'onboarding.toolsBody': '在对话中引用本地技能、图片和经过授权的工作目录。',
|
||||
'onboarding.autoTitle': '让群聊自动讨论直到收敛',
|
||||
'onboarding.autoBody': '自动讨论会让 Agent 持续接力、互相补充并逐轮优化答案。',
|
||||
'onboarding.autoTitle': '让 Agent 自主协作推进任务',
|
||||
'onboarding.autoBody': '设定目标和权限后,Agent 在自动讨论中接续工作;需要补充信息、授权或关键决策时,再由你介入。',
|
||||
'onboarding.previous': '上一张',
|
||||
'onboarding.next': '下一张',
|
||||
'onboarding.progress': '引导进度',
|
||||
@@ -118,6 +232,8 @@ export default {
|
||||
'agent.description.hermes': '适合开源推理、调研规划与持续时间较长的复杂任务。',
|
||||
'agent.description.openclaw': '适合自主本地工作流、持久执行与任务上下文管理。',
|
||||
'agent.description.workbuddy': '适合理解项目上下文、规划变更并务实地推动工作。',
|
||||
'agent.description.zcode': '沿用 Zcode CLI 的本机模型配置,执行本地任务并续接会话。',
|
||||
'agent.soul.zcode': 'Zcode 使用原生模型配置分析任务,在本地工作区内协作执行。',
|
||||
'agent.description.pi': '适合通过 Pi Agent 本机配置执行专注的本地任务。',
|
||||
'agent.description.kimi': '擅长长上下文阅读、密集文档分析、规划与复杂工作区任务。',
|
||||
'agent.description.mimo': '适合聚焦的本地规划、分析、实现与快速迭代。',
|
||||
@@ -149,8 +265,8 @@ export default {
|
||||
'conversation.renameTitle': '重命名对话',
|
||||
'conversation.emptyShowcase.0.title': '想快速推进时,先从一个 Agent 开始',
|
||||
'conversation.emptyShowcase.0.body': '在下方写下任务,Meldwork 会把上下文留在本地,方便你随时接着做',
|
||||
'conversation.emptyShowcase.1.title': '需要多一种判断时,再把 Agent 们叫到一起',
|
||||
'conversation.emptyShowcase.1.body': '新建群聊并 @ 需要的 Agent,让他们在同一份上下文里补充、复核和推进',
|
||||
'conversation.emptyShowcase.1.title': '提出目标,让 Agent 们协作完成',
|
||||
'conversation.emptyShowcase.1.body': '新建群聊、选择 Agent 并开启自动讨论,让它们接续工作、检查成果,需要你决策时再介入',
|
||||
'conversation.emptyShowcase.2.title': '给 Agent 补上更合适的技能和知识',
|
||||
'conversation.emptyShowcase.2.body': '先 @ Agent,再 @ 技能或知识库,它会带着这些能力开始工作',
|
||||
'conversation.you': '你',
|
||||
@@ -401,7 +517,7 @@ export default {
|
||||
'composer.auto': '顺序讨论',
|
||||
'composer.sequentialHint': '按顺序逐一回复:Agent 轮流作答,可参考前一位的回答,至最大轮数为止。',
|
||||
'composer.autoBeta': '自动讨论',
|
||||
'composer.autoBetaHint': 'Beta:Agent 可相互 @ 拉入讨论,由模型自主编排各轮协作。',
|
||||
'composer.autoBetaHint': 'Beta:Agent 自行请求同伴贡献、接续工作并检查成果;需要补充信息、授权或关键决策时由你介入。',
|
||||
'composer.autoBetaBadge': 'Beta',
|
||||
'composer.smartTeam': '智能组队',
|
||||
'composer.smartTeamOn': '开启',
|
||||
@@ -659,7 +775,7 @@ export default {
|
||||
'agentDetail.localSkills': '本地技能',
|
||||
'agentDetail.remoteSkills': '远端技能',
|
||||
'agentDetail.showInSidebar': '在侧边栏显示',
|
||||
'agentDetail.showInSidebarHint': '将此可用 Agent 保留在左侧导航中。',
|
||||
'agentDetail.showInSidebarHint': '隐藏后保留历史会话。不可用的 WorkBuddy 会自动从侧栏移除。',
|
||||
'shortcut.title': '快捷键',
|
||||
'shortcut.toggleSidebar': '收起或展开侧边栏',
|
||||
'shortcut.newGroup': '新建群聊',
|
||||
@@ -780,6 +896,7 @@ export default {
|
||||
'error.runCircuitBreaker': '失败次数过多,本次运行已停止。',
|
||||
'error.agentAuthRequired': '请先登录该 Agent 或完成凭据配置后再试。',
|
||||
'error.agentProcessFailed': 'Agent 进程在任务完成前执行失败。',
|
||||
'error.agentRepeatedOutput': '该 Agent 持续重复相同回复,本次调用已停止,已收到的内容已保留。请检查 CLI 的模型或服务商配置后再试。',
|
||||
'error.customAgentLimit': '最多可接入 32 个自定义 Agent。',
|
||||
'error.customAgentLabel': '请输入有效的 Agent 名称。',
|
||||
'error.customAgentDescription': '能力描述不能超过 240 个字符。',
|
||||
|
||||
@@ -1,4 +1,155 @@
|
||||
/* Agent discovery runs in the background: the sidebar shows skeleton rows
|
||||
.agent-discovery-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 90;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
padding: 32px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.agent-discovery-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(430px, 100%);
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 0;
|
||||
padding: 12px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.agent-discovery-mark {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
margin-bottom: 28px;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 9px 20px color-mix(in srgb, var(--accent) 18%, transparent));
|
||||
}
|
||||
|
||||
.agent-discovery-copy {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
text-shadow: 0 1px 14px color-mix(in srgb, var(--bg) 72%, transparent);
|
||||
}
|
||||
|
||||
.agent-discovery-kicker {
|
||||
margin: 0 0 9px;
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.agent-discovery-copy h1 {
|
||||
margin: 0;
|
||||
font-size: 30px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.agent-discovery-copy > p:not(.agent-discovery-kicker) {
|
||||
max-width: 360px;
|
||||
margin: 10px 0 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.agent-discovery-progress {
|
||||
width: min(260px, 100%);
|
||||
height: 3px;
|
||||
margin-top: 26px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--border) 68%, transparent);
|
||||
}
|
||||
|
||||
.agent-discovery-progress span {
|
||||
display: block;
|
||||
width: 46%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--onboarding-frame-gradient);
|
||||
box-shadow: 0 0 14px color-mix(in srgb, var(--accent) 34%, transparent);
|
||||
animation: agent-discovery-progress 1.35s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.agent-discovery-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin-top: 13px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.agent-discovery-status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
animation: agent-discovery-dot 1.25s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.agent-discovery-enter-active,
|
||||
.agent-discovery-leave-active {
|
||||
transition: opacity 0.28s ease;
|
||||
}
|
||||
|
||||
.agent-discovery-enter-active .agent-discovery-content,
|
||||
.agent-discovery-leave-active .agent-discovery-content {
|
||||
transition: opacity 0.28s ease, transform 0.28s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.agent-discovery-enter-from,
|
||||
.agent-discovery-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.agent-discovery-enter-from .agent-discovery-content,
|
||||
.agent-discovery-leave-to .agent-discovery-content {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.985);
|
||||
}
|
||||
|
||||
@keyframes agent-discovery-progress {
|
||||
0% { transform: translateX(-125%); opacity: 0.4; }
|
||||
55% { opacity: 1; }
|
||||
100% { transform: translateX(300%); opacity: 0.4; }
|
||||
}
|
||||
|
||||
@keyframes agent-discovery-dot {
|
||||
0%, 100% { opacity: 0.42; transform: scale(0.86); }
|
||||
50% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.agent-discovery-progress span,
|
||||
.agent-discovery-status-dot {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.agent-discovery-progress span {
|
||||
width: 58%;
|
||||
transform: translateX(36%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.agent-discovery-overlay { padding: 24px; }
|
||||
.agent-discovery-copy h1 { font-size: 26px; }
|
||||
.agent-discovery-mark { margin-bottom: 20px; }
|
||||
}
|
||||
/* The sidebar also shows skeleton rows during Agent discovery
|
||||
until the first detection returns. Only transform and opacity animate. */
|
||||
.sidebar-discovery {
|
||||
display: grid;
|
||||
@@ -53,37 +204,9 @@
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.boot-progress {
|
||||
width: 140px;
|
||||
height: 3px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--border) 70%, transparent);
|
||||
}
|
||||
|
||||
.boot-progress span {
|
||||
display: block;
|
||||
width: 40%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--accent);
|
||||
animation: boot-progress 1.2s var(--ease-standard) infinite;
|
||||
}
|
||||
|
||||
@keyframes boot-progress {
|
||||
from { transform: translateX(-110%); }
|
||||
to { transform: translateX(260%); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sidebar-discovery-row i,
|
||||
.sidebar-discovery-row b,
|
||||
.boot-progress span {
|
||||
.sidebar-discovery-row b {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.boot-progress span {
|
||||
width: 100%;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
:root {
|
||||
--pixel-blast-primary: #d92f24;
|
||||
--pixel-blast-secondary: #007d91;
|
||||
--pixel-blast-tertiary: #ed512f;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
@@ -90,6 +93,9 @@
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--pixel-blast-primary: #ff836d;
|
||||
--pixel-blast-secondary: #54f5ff;
|
||||
--pixel-blast-tertiary: #ffa36f;
|
||||
color-scheme: dark;
|
||||
--bg: #111618;
|
||||
--sidebar: #161c1e;
|
||||
@@ -203,7 +209,6 @@ select:disabled {
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.conversation-nav,
|
||||
.trace-panel-scroll,
|
||||
.settings-panel,
|
||||
.agent-home,
|
||||
|
||||
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user