Compare commits

...
Author SHA1 Message Date
Ryder Sun 709a9e9391 release: publish Meldwork V1.0.4
Ship natural group discussion routing and task-scoped native session continuity. Update package versions, bilingual release links, changelog, and verification notes.
2026-09-01 17:47:51 +08:00
Ryder Sun 897b4ee054 docs: expand supported cli table to six columns 2026-09-01 14:11:30 +08:00
Ryder Sun fa56c3575d docs: restore README banner and CLI layout 2026-09-01 12:52:18 +08:00
Ryder Sun fad1cba603 docs: clarify README agent landscape and demos 2026-09-01 11:53:12 +08:00
Ryder Sun 050a457a40 docs: sharpen README category keywords 2026-09-01 11:39:37 +08:00
Ryder Sun 7ab51fd621 docs: align bilingual README terminology 2026-09-01 11:36:46 +08:00
Ryder Sun 91a0aad3f2 docs: refine bilingual README for discoverability 2026-09-01 11:31:58 +08:00
Ryder Sun a68c53bd86 docs: align main license references 2026-09-01 10:21:39 +08:00
Ryder Sun 3a539d8e7c docs: improve AI discoverability and project positioning 2026-09-01 10:20:36 +08:00
Ryder Sun 2faad0c322 test: align Pi invocation expectations 2026-08-31 18:38:17 +08:00
Ryder Sun 33842bc959 Merge branch 'feature/Logic-Refactoring-Conversation-Mode'
# Conflicts:
#	desktop/src/agents/cli/cli-event-profiles.cjs
#	desktop/src/agents/cli/cli-invocations.cjs
#	desktop/src/agents/cli/cli-output-parsers.cjs
#	desktop/src/agents/cli/cli-runtime-event-mappers.cjs
#	desktop/test/agents/cli/cli-adapters-agent-protocols.test.cjs
#	desktop/test/agents/cli/cli-event-profiles.test.cjs
#	desktop/test/agents/cli/cli-runtime-event-protocols.test.cjs
#	desktop/test/fixtures/agent-output/manifest.json
#	desktop/test/fixtures/agent-output/pi.jsonl
#	desktop/test/workspace/local-workspace-native-streaming.test.cjs
2026-08-31 18:37:56 +08:00
Ryder Sun 61a71f3439 refactor: make group discussions natural and session-continuous
Run sequential mode strictly in configured CLI order, let auto-beta route later turns from canonical mentions, and persist one native Agent session per task across rounds and restarts. Remove rigid review framing from natural prompts and add recovery, routing, and continuity coverage.
2026-08-31 18:05:14 +08:00
Ryder Sun 782f839d4f feat: sync license and agent runtime updates 2026-08-25 14:54:58 +08:00
47 changed files with 2910 additions and 422 deletions

No files matched your search

+20
View File
@@ -1,5 +1,25 @@
# Changelog
## 0.1.4 - 2026-09-01 (Meldwork-V1.0.4 prerelease)
### Natural Group Discussions
- Sequential discussion mode now runs one Agent at a time in the configured CLI order and records every round visibly.
- Auto Discussion starts with concurrent proposals, then lets Agent responses route the next turn through exact final-line `@Agent` mentions. One mention runs one Agent; multiple mentions run concurrently.
- Natural discussion prompts allow Agents to exchange views before work and review instead of forcing every turn into a review-only role.
### Session And Recovery
- Reuses one native Agent session per Agent task across discussion rounds and restarts.
- Persists round messages and checkpoints before and after Agent execution so completed turns are recovered without duplicate reruns.
- Rejects stale operation and snapshot bindings before late results can be committed.
### Verification And Distribution
- Focused natural discussion coverage: 17/17 tests passed.
- Desktop syntax checks and `git diff --check` passed.
- The Apple silicon DMG and ZIP are ad-hoc signed and not notarized. Live CLI behavior still depends on each installed Agent's version, authentication, Provider, and capabilities.
## 0.1.3 - 2026-08-18 (Meldwork-V1.0.3 prerelease)
### Concurrent Conversations
+51
View File
@@ -0,0 +1,51 @@
# Code of Conduct
Meldwork is a workspace for people and Agents to build together. We want issues,
pull requests, discussions, and community spaces to stay useful, direct, and
respectful.
## Expected Behavior
- Be clear, constructive, and respectful when giving feedback.
- Assume good intent while keeping discussions focused on facts, tradeoffs, and outcomes.
- Respect different experience levels, backgrounds, languages, and working styles.
- Keep disagreements technical, specific, and connected to the project.
- Help maintainers keep issues and pull requests focused.
- Protect private prompts, credentials, customer information, and other sensitive data.
## Unacceptable Behavior
The following behavior is not acceptable in Meldwork project spaces:
- Personal attacks, insults, harassment, or sustained disruption.
- Demeaning comments about identity, background, language, ability, or experience.
- Threats, intimidation, stalking, or unwanted contact.
- Publishing another person's private information without consent.
- Spam, trolling, discrimination, or repeated off-topic posting.
- Submitting malicious code, secrets, private data, or intentionally misleading reports.
- Any behavior maintainers reasonably judge to make the project unsafe or unproductive.
## Maintainer Responsibilities
Maintainers may edit or remove comments, close issues, limit participation, or
block participants when needed to protect the project community. Enforcement
should be proportional, documented when practical, and focused on keeping
collaboration healthy.
## Reporting
Report conduct concerns privately through the repository's GitHub contact or
security reporting channel. Do not open a public issue for sensitive reports.
Please include:
- a short description of what happened;
- links, screenshots, or message references when available;
- whether you want follow-up, confidentiality, or no direct reply.
Reports will be reviewed by project maintainers. We will handle reports with
care and limit access to people who need the information to respond.
This Code of Conduct applies wherever the project or its maintainers represent
the Meldwork community. It is adapted from the Contributor Covenant and the
community standards used by comparable open-source projects.
+10 -13
View File
@@ -1,17 +1,14 @@
# Commercial Use And Licensing
Meldwork is source-available under the [Meldwork Non-Commercial Source License](LICENSE). The public repository can be inspected, evaluated, run, and modified for non-commercial purposes, but it is not licensed for 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.
Any commercial use requires prior written permission from the copyright holder. This includes monetizing Meldwork, reselling or hosting it, embedding it in a proprietary product, using it to provide paid services, or using it as part of a for-profit organization's business operations.
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.
The copyright holder may offer a separate commercial license for organizations that need permission for:
- proprietary redistribution or resale of Meldwork or a modified version;
- embedding Meldwork code in a closed-source product;
- offering a hosted, managed, or network-accessible version;
- internal business operations at a for-profit organization;
- separately licensed commercial features, enterprise integrations, support, or managed deployment.
A commercial license does not change the licenses of third-party dependencies, Agent products, names, models, or logos. The repository license also does not grant trademark rights to the Meldwork name, logo, screenshots, or other brand assets.
This page is a practical summary and does not replace the license text or legal advice. When a planned use may be commercial, contact the repository owner through GitHub before using, distributing, hosting, reselling, or integrating the product.
Before distributing a product that includes third-party materials, review the
relevant notices and preserve the required attribution and license information.
+6 -5
View File
@@ -1,8 +1,8 @@
# Contributing
Meldwork is source-available under the [Meldwork Non-Commercial Source License](LICENSE). Contributions are welcome when they improve the local-first desktop product, documentation, tests, or security posture.
Meldwork is licensed under the [Apache License 2.0](LICENSE). Contributions are welcome when they improve the local-first desktop product, documentation, tests, or security posture.
Unless a separate written agreement applies, a contribution accepted into this repository is available under the repository license. The project may also offer separately licensed commercial editions. Before external code is incorporated into a separately licensed commercial build, the maintainer may require a reviewed contributor-license agreement that grants the necessary relicensing rights. Opening a pull request by itself does not transfer copyright or silently grant those additional rights.
Unless a separate written agreement applies, a contribution accepted into this repository is available under the repository license. Opening a pull request does not transfer copyright beyond the rights granted by the Apache License 2.0.
## Ground Rules
@@ -11,8 +11,9 @@ Unless a separate written agreement applies, a contribution accepted into this r
- Do not add remote services, telemetry, credential collection, or server dependencies without explicit project approval.
- Preserve the local-first boundary: renderer code must not gain unrestricted filesystem, shell, Provider credential, or executable-path access.
- Include focused tests for behavior, security boundary, storage, or IPC changes.
- Confirm that all submitted code and assets are yours to license and do not introduce incompatible license obligations.
- Confirm that all submitted code and assets are yours to contribute and do not introduce incompatible license obligations.
## Commercial Editions
## Community Standards
Commercial use of Meldwork requires prior written permission. Commercial editions, proprietary integrations, support, and other paid offerings may use separate terms; see [COMMERCIAL_USE.md](COMMERCIAL_USE.md).
Please follow the [Code of Conduct](CODE_OF_CONDUCT.md) in issues, pull
requests, discussions, and other project spaces.
+177 -58
View File
@@ -1,82 +1,201 @@
Meldwork Non-Commercial Source License 1.0
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Copyright (c) 2026 Ryder-MHumble.
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
This license applies to Meldwork and the source code, documentation, assets, and
other materials in this repository, except where a file or third-party notice
states a different license.
1. Definitions.
Meldwork is source-available, not open source in the OSI sense. Commercial use is
not permitted without prior written permission from the copyright holder.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
1. Allowed non-commercial use
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
You may download, inspect, run, copy, and modify Meldwork for personal learning,
research, evaluation, security review, and other non-commercial purposes.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
You may share unmodified or modified copies for non-commercial purposes if you:
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
- keep this license and all copyright, attribution, and trademark notices;
- clearly mark your changes when you distribute a modified copy;
- do not remove or obscure third-party notices; and
- do not imply that the copyright holder endorses your copy or service.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
2. Commercial use requires permission
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
You may not use Meldwork for a commercial purpose without a separate written
license or other written permission from the copyright holder.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
Commercial purpose includes, without limitation:
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
- selling, renting, sublicensing, or otherwise monetizing Meldwork or a modified
version;
- offering Meldwork or a modified version as a hosted, managed, consulting,
outsourcing, integration, or support service;
- embedding Meldwork code, UI, workflow, or packaged binaries in a proprietary,
paid, or revenue-generating product;
- using Meldwork to provide paid deliverables, paid automation, paid Agent
operations, or other business services to a customer; and
- use by or for a for-profit company, commercial product team, or revenue-driven
organization as part of its business operations.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
If you want to use Meldwork commercially, contact the repository owner and obtain
written permission before using, distributing, hosting, reselling, or integrating
the product.
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
3. No trademark license
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
This license does not grant rights to use the Meldwork name, logo, product
identity, screenshots, or other brand assets except as needed to describe an
unaltered copy of the software in a truthful, non-confusing way.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Third-party materials
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
Third-party software, Agent products, models, names, logos, dependencies, and
other materials remain subject to their own licenses and terms. This license does
not give you rights that the copyright holder does not own.
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
5. Contributions
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
Unless a separate written agreement says otherwise, contributions submitted to
this repository may be used under this license. The copyright holder may require
a separate contributor agreement before accepting contributions intended for a
commercially licensed edition.
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
6. Termination
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
If you violate this license, your rights under it terminate automatically. The
copyright holder may reinstate rights in writing after the violation is cured.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
7. No warranty
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
Meldwork is provided "as is", without warranties or conditions of any kind,
including implied warranties of merchantability, fitness for a particular
purpose, title, and non-infringement. You are responsible for evaluating whether
the software is suitable and lawful for your use.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
8. Limitation of liability
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
To the maximum extent permitted by law, the copyright holder and contributors
will not be liable for any damages arising from or related to the software or
this license, including direct, indirect, incidental, consequential, special,
exemplary, punitive, or business-interruption damages.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+1 -3
View File
@@ -1,9 +1,7 @@
Meldwork
Copyright 2026 Ryder-MHumble and Meldwork contributors.
Meldwork is licensed under the Meldwork Non-Commercial Source License. See
LICENSE. Commercial use requires separate written permission from the copyright
holder.
Meldwork is licensed under the Apache License 2.0. See LICENSE.
Third-party Agent names, product names, and logos remain the property of their
respective owners. They are used only to identify compatible integrations and
+89 -62
View File
@@ -21,106 +21,133 @@
The current preview is a local work cell for supported Agent CLIs. It keeps participants explicit, context scoped, collaboration bounded, run state inspectable, and human review in the loop. Selected Agents can now respond concurrently or move through independent proposals, peer negotiation, responsibility-based work, single-writer integration, and independent verification around one goal.
<p align="center">
<a href="https://github.com/Ryder-MHumble/Meldwork/releases/download/Meldwork-V1.0.3/Meldwork-0.1.3-arm64.dmg"><strong>Download Meldwork V1.0.3 for Apple silicon macOS</strong></a>
<a href="https://github.com/Ryder-MHumble/Meldwork/releases/download/Meldwork-V1.0.4/Meldwork-0.1.4-arm64.dmg"><strong>Download Meldwork V1.0.4 for Apple silicon macOS</strong></a>
· <a href="architecture.md">Architecture</a>
· <a href="LICENSE">License</a>
</p>
## The missing layer in the Agent workforce
## Who it is for
Agent supply is growing quickly. The organization around it is not.
Use Meldwork when you:
Today, people still choose every Agent, move every piece of context, explain every role, reconcile conflicting answers, and carry the final accountability. Parallel managers improve throughput. Agent frameworks let developers program workflows. Single-Agent products improve one worker.
- already have multiple local Agent CLIs and need one task to span them;
- need independent review for code, research, or product analysis before choosing a path;
- need inspectable traces, evidence, and human review before workspace writes.
Meldwork is focused on the work between them:
## Workflow
- one shared goal and acceptance boundary;
- independent viewpoints instead of hidden routing;
- explicit authority, responsibility, handoffs, and stopping conditions;
- artifacts and evidence that survive Agent changes;
- a human-controlled adoption decision.
1. **Select** the local Agents and participants.
2. **Scope** the goal, working directory, context, and permissions.
3. **Run** Direct, Concurrent Responses, or Auto Discussion V4.
4. **Review and adopt** the result, evidence, and any Human Gate before changing the workspace.
## What Meldwork is building
## Collaboration modes
The collaboration mechanism is not a hidden preset A → B → C assignment. The user selects the participants; those Agents expose the solution space, negotiate responsibilities, and execute inside Harness-enforced context, permission, budget, recovery, and evidence boundaries.
The selected-Agent proposal, challenge, responsibility, work, integration, and verification loop is available today. Automatic participant selection from a broader roster, remote Agents, enterprise governance, and outcome-driven reputation remain future direction.
### Demo
| Local Agent discovery | Direct multimodal work | Concurrent multi-Agent work |
| Mode | What happens | Best for |
| --- | --- | --- |
| [![Meldwork detecting supported local Agent CLIs](assets/meldwork-agent-discovery.png)](assets/meldwork-agent-discovery.png) | [![A direct Agent conversation returning local files and media](assets/meldwork-direct-multimodal.png)](assets/meldwork-direct-multimodal.png) | [![A group conversation with user-selected Agent replies](assets/meldwork-group-collaboration.png)](assets/meldwork-group-collaboration.png) |
| See which supported Agents are ready. | Keep prompts, files, permissions, and compatible sessions together. | Run concurrent replies or an Agent-negotiated collaboration with the participants you selected. |
| **Direct** | One selected Agent keeps its conversation and native session when supported. | Focused work with one Agent. |
| **Concurrent Responses** | Selected Agents receive the same frozen task snapshot and return independent replies in stable order. | Comparing approaches before choosing one. |
| **Auto Discussion V4** | The first round runs concurrent proposals. Later turns follow the discussion: one Agent can route the next turn with `@Agent`, or several Agents can be selected for a concurrent response. Native Agent sessions continue across rounds. | Multi-round work that benefits from discussion, flexible routing, and review. |
## What makes Meldwork different
Participants are always selected by the user. Automatic selection from a larger roster is outside the current preview.
### Harness design
## How Meldwork differs
The Harness is the control plane that keeps conversations, context, adapters, streams, and durable state aligned in one workspace. It is what makes a run continuous across sessions, timeouts, recovery, and Agent changes.
Meldwork is a local-first AI Agent workspace for multi-Agent review and decision traceability, not a terminal, cloud Agent fleet, communication network, or programmable orchestration framework. It connects the local Agent tools you already use to a decision-ready review workflow and keeps independent findings, evidence, responsibility, and the human adoption decision visible in one local work cell.
<p align="center">
<img src="frontend/public/logos/Harness-readme.png" alt="Meldwork Harness" width="100%">
</p>
| Project | Category | What it focuses on | Meldwork's difference |
| --- | --- | --- | --- |
| [Buzz](https://github.com/block/buzz) | Agent communication network | Identity, channels, events, and persistent collaboration. | Case-scoped independent judgments with evidence-backed Decision and Disposition. |
| [Pragma](https://github.com/pqpo/pragma) | Method and workflow runtime | Reusable Expert, Flow, Memory, Evaluation, and DSL assets. | Keeps disagreement, re-checks, and adoption records visible before a workflow is fixed. |
| [Munder Difflin](https://github.com/chaitanyagiri/munder-difflin) | Visual Agent company | Boss -> Manager -> Workers, with task and activity status. | Tracks Finding -> Evidence -> Decision -> Disposition without hiding dissent behind a central manager. |
| [Superset](https://github.com/superset-sh/superset) / [Conductor](https://www.conductor.build/) / [Vibe Kanban](https://github.com/BloopAI/vibe-kanban) | Parallel coding workspaces | Parallel coding Agents, isolated workspaces, diffs, and merges. | Local-first review across heterogeneous CLIs, not terminal count, worktree throughput, or cloud sandboxes. |
| [cmux](https://github.com/manaflow-ai/cmux) | Agent-native terminal | Low-friction terminals, notifications, and multitasking. | Adds frozen context, independent results, and a human adoption gate. |
| [Nimbalyst](https://github.com/nimbalyst/nimbalyst) | Agent and artifact workspace | Parallel Agents with Markdown, mockups, and diagrams. | Places evidence and accountable decisions alongside artifacts. |
| [Paperclip](https://github.com/paperclipai/paperclip) | Agent organization management | Companies, roles, budgets, approvals, and org charts. | Grounds responsibility in real findings and outcomes, not a virtual company. |
| [MCP Agent Mail](https://github.com/Dicklesworthstone/mcp_agent_mail) | Agent communication infrastructure | Identity, inboxes, threads, and advisory file leases. | Preserves Case and review semantics above the transport layer. |
| LangGraph / CrewAI / AutoGen / OpenAI Agents SDK / Google ADK | Programmable multi-Agent frameworks | Graphs, roles, routing, tools, and approvals for developers. | Delivers a desktop review workflow without requiring teams to build an orchestration framework. |
| Warp Oz / Devin / Factory / OpenHands Cloud | Cloud Agent control planes | Hosted sandboxes, remote execution, and Agent fleets. | Runs in the user's local Electron work cell with existing CLIs and local data boundaries. |
This is a comparison of working models, not a feature-scorecard claiming Meldwork should replace every tool.
## See it in action
| Approach | Examples | Best at | Main tradeoff | Meldwork's choice |
| --- | --- | --- | --- | --- |
| One Agent CLI or AI IDE | Claude Code, Codex, Cursor, Windsurf | Deep work inside one Agent or product ecosystem | Moving to another Agent still requires a manual handoff | Preserve work across heterogeneous Agents |
| Parallel coding-Agent managers | Superset, Vibe Kanban, Nimbalyst, Conductor | Running multiple coding tasks and isolated workspaces in parallel | Primarily optimized for throughput and code delivery | Optimize proposal quality, responsibility, evidence, and adoption |
| Cloud Agent control planes | GitHub Agent HQ, Warp Oz, Devin, Factory, OpenHands Cloud | Dispatching and observing remote jobs, sandboxes, and Agent fleets | Work is organized around cloud or platform infrastructure | Start local-first and bring your existing Agents |
| Programmable Agent frameworks | LangGraph, CrewAI, AutoGen, Agents SDK, ADK | Building custom graphs, roles, routing, and automation | Developers must design and maintain the orchestration | Deliver an organization and acceptance experience to users |
| Multiple terminals and scripts | tmux, shell scripts, manual copy and paste | Maximum access to native Agent capabilities | The user becomes the router, context bus, and final judge | Make the work, boundaries, and decisions durable |
| **Meldwork** | Supported local Agent CLIs; remote supply is direction | Concurrent independent responses and selected-Agent proposal, negotiation, responsibility, evidence, and verification | Local single-user preview today; participants are still selected by the user | Build the organization layer for the Agent workforce |
<table>
<tr>
<th>Local Agent discovery</th>
<th>Multi-agent review</th>
<th>Direct multimodal work</th>
</tr>
<tr>
<td align="center"><a href="assets/meldwork-agent-discovery.png"><img src="assets/meldwork-agent-discovery.png" alt="Meldwork local-first multi-agent workspace detecting Agent CLIs" width="320" height="205"></a></td>
<td align="center"><a href="assets/meldwork-multi-agent-review.png"><img src="assets/meldwork-multi-agent-review.png" alt="Meldwork multi-agent collaboration with evidence and human review" width="320" height="205"></a></td>
<td align="center"><a href="assets/meldwork-direct-multimodal.png"><img src="assets/meldwork-direct-multimodal.png" alt="Meldwork direct Agent workspace with local files and media" width="320" height="205"></a></td>
</tr>
</table>
If one Agent and one work surface are enough, use the native tool. Use Meldwork when handoff, independent judgment, authority, evidence, and acceptance become part of the problem.
Example: give Codex, Claude Code, and Gemini CLI the same change-review context, compare their independent findings, then adopt only the evidence-backed result you approve.
## Install
## Supported local Agent CLIs
The current release-validated target is Apple silicon macOS.
Meldwork detects and invokes an installed command when its adapter and the CLI version are compatible:
1. Open the [Meldwork V1.0.3 prerelease](https://github.com/Ryder-MHumble/Meldwork/releases/tag/Meldwork-V1.0.3).
2. Download Meldwork-0.1.3-arm64.dmg.
3. Drag Meldwork into Applications and try to open it once.
4. This prerelease is ad-hoc signed, not signed with an Apple Developer ID, and not Apple-notarized. If macOS blocks it, open **System Settings → Privacy & Security**, find the Meldwork warning, choose **Open Anyway**, and confirm.
5. Connect a supported Agent CLI already installed on the computer, or configure an independent Provider profile where supported.
<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>
</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>
</tr>
</table>
Use only artifacts downloaded from the official Release.
Approved Agent Connectors can be added through the [Agent Connector SDK](docs/agent-connector-sdk.md); custom executable Agents use the desktop custom-Agent path. See the [desktop guide](desktop/README.md) for the full adapter and capability matrix.
<details>
<summary><strong>Build from source</strong></summary>
## Quick start
Requires Node.js 22.12 or newer and npm.
### Apple silicon macOS preview
~~~bash
Download [`Meldwork-0.1.4-arm64.dmg`](https://github.com/Ryder-MHumble/Meldwork/releases/download/Meldwork-V1.0.4/Meldwork-0.1.4-arm64.dmg) from the [official V1.0.4 prerelease](https://github.com/Ryder-MHumble/Meldwork/releases/tag/Meldwork-V1.0.4), move Meldwork to Applications, and install at least one supported local Agent CLI. The prerelease is ad-hoc signed and not notarized; macOS may require **Open Anyway** in **System Settings -> Privacy & Security** on first launch.
### Run from source
Prerequisites: Node.js `22.12+` and npm.
```bash
npm --prefix frontend ci
npm --prefix desktop ci
npm --prefix desktop run dev
~~~
```
Run the repository checks before distributing a build:
Before distributing a build:
~~~bash
```bash
npm --prefix frontend test
npm --prefix frontend run build
npm --prefix frontend run build:desktop
npm --prefix desktop test
~~~
```
</details>
## Current boundary
## Trust boundaries
Meldwork is a local Electron app, not a hosted Agent fleet or a general Agent framework. The current preview does not provide remote/cloud/channel Agent execution, automatic participant selection, enterprise SSO/RBAC/governance, or an Outcome Network. Local-first does not mean fully offline: a selected Agent may send prompts, attachments, or Skills to its configured Provider. Workspace writes are opt-in workflow controls, not an operating-system sandbox.
- Conversations and Meldwork orchestration state stay local.
- Model requests still follow the Agent and Provider you choose; local-first does not mean every model runs offline.
- Credentials use operating-system-backed secure storage where supported.
- Workspace writes are explicit and opt-in.
- Raw chain-of-thought, executable paths, secrets, and unrestricted command output are not exposed to the renderer.
- Cloud, Channel, automatic participant selection, enterprise governance, and an Outcome Network are not current release promises.
## Docs
Read the [architecture](architecture.md) and [Agent Connector contract](docs/agent-connector-sdk.md) for the public technical boundary.
- [Architecture and product boundary](architecture.md)
- [Desktop setup and Agent matrix](desktop/README.md)
- [Agent Connector SDK](docs/agent-connector-sdk.md)
- [AI discoverability index](docs/ai-discoverability.md)
- [Contributing](CONTRIBUTING.md)
- [Security policy](SECURITY.md)
## License
Meldwork is source-available under the [Meldwork Non-Commercial Source License](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 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).
+89 -64
View File
@@ -18,111 +18,136 @@
**多数工具是在帮人类管更多 Agent;Meldwork 解决的是另一件事:让多 Agent 协作变得可见、可追责、可复用。**
当前预览版是一个面向已支持本地 Agent CLI 的本地工作单元。它把参与者、上下文、协作边界、运行状态和人类复核放在同一个工作空间里。已选 Agent 现在可以并发回复,也可以围绕同一目标先独立提案、再协商职责、分工执行、单写者整合并由其他 Agent 独立复核。
当前预览版是一个面向已支持本地 Agent CLI 的本地工作单元。它把参与者、上下文、协作边界、运行状态和人类复核放在同一个工作空间里。已选 Agent 现在可以并发回复,也可以围绕同一目标先独立提案,再根据讨论互相指定下一位参与者,最后协作完成工作并进行复核。
<p align="center">
<a href="https://github.com/Ryder-MHumble/Meldwork/releases/download/Meldwork-V1.0.3/Meldwork-0.1.3-arm64.dmg"><strong>下载 Meldwork V1.0.3 Apple 芯片 macOS 版</strong></a>
<a href="https://github.com/Ryder-MHumble/Meldwork/releases/download/Meldwork-V1.0.4/Meldwork-0.1.4-arm64.dmg"><strong>下载 Meldwork V1.0.4 Apple 芯片 macOS 版</strong></a>
· <a href="architecture.md">架构</a>
· <a href="LICENSE">许可证</a>
</p>
## Agent workforce 缺少的那一层
## 适合谁
Agent 的供给正在快速增长,但围绕它们工作的组织能力并没有跟上。
当你有以下需求时,Meldwork 更适合:
今天,人类仍然需要自己选 Agent、搬运上下文、解释角色、处理冲突答案,并承担最终责任。并行管理器优化吞吐量,多 Agent 框架让开发者编写 Workflow,单 Agent 产品优化一个 Worker。
- 已经安装多个本地 Agent CLI,希望让同一任务跨 Agent 协作;
- 需要在代码评审、研究或产品分析中获得独立判断,再选择方案;
- 需要在写入工作区前检查可追溯的运行轨迹、证据并保留人工复核。
Meldwork 关注的是中间那层工作:
## 工作流程
- 一个共享 Goal 和明确验收边界;
- 独立判断,而不是隐藏路由;
- 明确的权限、责任、交接和停止条件;
- 不随 Agent 切换而丢失的 Artifact 与 Evidence;
- 由人类掌握的最终采用决定。
1. **选择**本地 Agent 和参与者。
2. **限定**目标、工作目录、上下文和权限。
3. **运行**直接会话、并发回复或 Auto Discussion V4。
4. **复核并采用**结果、证据和待处理的人工审批门(Human Gate),再写入工作区。
## 协作模式
## Meldwork 正在构建什么
当前协作机制不是 Harness 暗中预设的 A → B → C 分配链。用户选定参与者后,Agent 先展开解空间、自主协商职责,再在 Harness 管理的上下文、权限、预算、恢复和证据边界内执行。
已选 Agent 的独立提案、交叉质询、职责协商、分工执行、整合和复核现在已经可用。从更大候选池自动选人组队、远程 Agent、企业治理和 Outcome 驱动的声誉系统仍是未来方向。
### 效果演示
| 本地 Agent 检测 | 单聊中的多模态工作 | 并发多 Agent 协作 |
| 模式 | 运行方式 | 适合场景 |
| --- | --- | --- |
| [![Meldwork 检测已支持本地 Agent CLI](assets/meldwork-agent-discovery.png)](assets/meldwork-agent-discovery.png) | [![Agent 在直接会话中返回本地文件和媒体](assets/meldwork-direct-multimodal.png)](assets/meldwork-direct-multimodal.png) | [![用户选择多个 Agent 并分别检查结果](assets/meldwork-group-collaboration.png)](assets/meldwork-group-collaboration.png) |
| 查看哪些受支持 Agent 已就绪。 | Prompt、文件、权限和兼容 Session 留在一起。 | 让已选 Agent 并发回复,或进入由 Agent 自主协商职责的协作流程。 |
| **直接会话** | 一个已选 Agent 在适配器支持时保持对话和原生会话。 | 使用单个 Agent 完成聚焦工作。 |
| **并发回复** | 已选 Agent 收到同一份冻结任务快照,并按稳定顺序返回独立回复。 | 在选择方案前比较不同路径。 |
| **Auto Discussion V4** | 首轮并发提案。后续轮次由 Agent 根据讨论结果通过最后一行的 `@Agent` 决定下一位参与者;@ 一个 Agent 时单独运行,@ 多个 Agent 时并发运行。每个 Agent 在同一任务中持续使用原生会话。 | 需要讨论、灵活分工和复核的多轮工作。 |
参与者始终由用户选择。从更大候选池自动选人不属于当前预览版。
## Meldwork 和别的产品有什么区别
## Meldwork 与相邻产品的区别
### Harness设计
Meldwork 是面向多 Agent 评审与决策可追溯性的本地优先 AI Agent 工作空间,不是终端、云端 Agent Fleet、通信网络或可编程编排框架。它把你已经在使用的本地 Agent 工具连接到可形成决定的评审流程,并在一个本地工作单元中保留独立发现、证据、责任和人工采用决定。
Harness 是把会话、上下文、适配器、流和持久化状态拴在一起的控制平面。它让一次任务可以跨会话、超时、恢复和 Agent 切换继续下去。
| 项目 | 类别 | 主要解决什么 | Meldwork 的区别 |
| --- | --- | --- | --- |
| [Buzz](https://github.com/block/buzz) | Agent 通信网络 | 身份、频道、事件和持续协作。 | 面向单个 Case 的独立判断,以及有证据支持的 Decision 和 Disposition。 |
| [Pragma](https://github.com/pqpo/pragma) | 方法与工作流运行时 | 可复用的 Expert、Flow、Memory、Evaluation 和 DSL 资产。 | 在流程固定前保留分歧、复验和采用记录。 |
| [Munder Difflin](https://github.com/chaitanyagiri/munder-difflin) | 可视化 Agent 公司 | Boss -> Manager -> Workers,以及任务和活动状态。 | 追踪 Finding -> Evidence -> Decision -> Disposition,不让中心经理隐藏分歧。 |
| [Superset](https://github.com/superset-sh/superset) / [Conductor](https://www.conductor.build/) / [Vibe Kanban](https://github.com/BloopAI/vibe-kanban) | 并行编码工作台 | 并行 Coding Agent、隔离工作区、Diff 和 Merge。 | 面向异构 CLI 的本地优先评审,不以终端数量、Worktree 吞吐量或云 Sandbox 竞争。 |
| [cmux](https://github.com/manaflow-ai/cmux) | Agent 原生终端 | 低摩擦终端、通知和多任务处理。 | 增加冻结上下文、独立结果和人工采用门。 |
| [Nimbalyst](https://github.com/nimbalyst/nimbalyst) | Agent 与 Artifact 工作空间 | 并行 Agent,以及 Markdown、Mockup 和 Diagram。 | 在 Artifact 旁边保留证据和可追责的决定。 |
| [Paperclip](https://github.com/paperclipai/paperclip) | Agent 组织管理 | 公司、岗位、预算、审批和组织图。 | 将责任落在真实 Finding 和结果上,而不是虚拟公司。 |
| [MCP Agent Mail](https://github.com/Dicklesworthstone/mcp_agent_mail) | Agent 通信基础设施 | 身份、收件箱、Thread 和 advisory file lease。 | 在传输层之上保留 Case 和评审语义。 |
| LangGraph / CrewAI / AutoGen / OpenAI Agents SDK / Google ADK | 可编程多 Agent 框架 | 面向开发者的 Graph、角色、路由、工具和审批。 | 团队无需编写编排框架代码即可获得桌面评审流程。 |
| Warp Oz / Devin / Factory / OpenHands Cloud | 云端 Agent 控制面 | 托管 Sandbox、远程执行和 Agent Fleet。 | 在本地 Electron 工作单元中使用现有 CLI,并保持本地数据边界。 |
<p align="center">
<img src="frontend/public/logos/Harness-readme.png" alt="Meldwork Harness" width="100%">
</p>
## 运行示例
这张表比较的是不同工作范式,不是一张声称 Meldwork 应当替代所有工具的功能勾选表。
<table>
<tr>
<th>本地 Agent 检测</th>
<th>多 Agent 评审</th>
<th>直接会话中的多模态工作</th>
</tr>
<tr>
<td align="center"><a href="assets/meldwork-agent-discovery.png"><img src="assets/meldwork-agent-discovery.png" alt="Meldwork 本地优先多 Agent 工作空间检测 Agent CLI" width="320" height="205"></a></td>
<td align="center"><a href="assets/meldwork-multi-agent-review.png"><img src="assets/meldwork-multi-agent-review.png" alt="Meldwork 多 Agent 协作、证据与人工复核" width="320" height="205"></a></td>
<td align="center"><a href="assets/meldwork-direct-multimodal.png"><img src="assets/meldwork-direct-multimodal.png" alt="Meldwork 直接 Agent 工作空间处理本地文件和媒体" width="320" height="205"></a></td>
</tr>
</table>
| 范式 | 例子 | 最擅长什么 | 主要代价 | Meldwork 的选择 |
| --- | --- | --- | --- | --- |
| 单 Agent CLI / AI IDE | Claude Code、Codex、Cursor、Windsurf | 在一个 Agent 或产品生态内完成深度工作 | 换 Agent 时仍需手工交接 | 保留跨异构 Agent 的工作 |
| 并行 Coding-Agent 工作台 | Superset、Vibe Kanban、Nimbalyst、Conductor | 并行运行多个 Coding 任务和隔离工作区 | 主要优化吞吐量和代码交付 | 优化方案质量、责任、证据和采用 |
| Cloud Agent 控制面 | GitHub Agent HQ、Warp Oz、Devin、Factory、OpenHands Cloud | 派发和观察远程 Job、Sandbox 与 Agent Fleet | 工作围绕云基础设施和平台边界 | 先从 Local-first 和 BYO Agent 切入 |
| 可编程多 Agent 框架 | LangGraph、CrewAI、AutoGen、Agents SDK、ADK | 开发者自定义 Graph、角色、路由和自动化 | 需要开发者设计并维护编排 | 面向使用者交付组织与验收体验 |
| 多终端与脚本 | tmux、Shell、手工复制粘贴 | 最大化原生 Agent 访问权 | 人类成为 Router、Context Bus 和最终裁决者 | 让工作、边界和决定保持持续 |
| **Meldwork** | 已支持本地 Agent CLI;远程供给属于方向 | 并发独立回复,以及已选 Agent 之间的提案、协商、责任、Evidence 和复核 | 当前仍是本地单用户预览;参与者仍由用户选定 | 构建 Agent workforce 的组织层 |
示例:把同一份变更评审上下文交给 Codex、Claude Code 和 Gemini CLI,比较各自的独立发现,再只采用你批准且有证据支持的结果。
如果一个 Agent 和一个工作面已经足够,直接使用原生工具。只有当交接、独立判断、权限、证据和验收成为问题时,才需要 Meldwork。
## 支持的本地 Agent CLI
## 安装
当适配器和 CLI 版本兼容时,Meldwork 会检测并调用已安装的命令:
当前经过发布验证的目标是 Apple 芯片 macOS。
<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>
</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>
</tr>
</table>
1. 打开 [Meldwork V1.0.3 预发布页](https://github.com/Ryder-MHumble/Meldwork/releases/tag/Meldwork-V1.0.3)。
2. 下载 Meldwork-0.1.3-arm64.dmg。
3. 将 Meldwork 拖入“应用程序”,然后先尝试打开一次。
4. 当前预发布版使用临时签名,没有 Apple Developer ID 签名,也尚未经过 Apple 公证。如果 macOS 阻止启动,请打开“系统设置 → 隐私与安全性”,找到 Meldwork 提示,点击“仍要打开”并确认。
5. 连接电脑上已安装的受支持 Agent CLI,或在兼容情况下单独配置 Provider。
已批准的 Agent Connector 可通过 [Agent Connector SDK](docs/agent-connector-sdk.md) 接入;自定义可执行 Agent 使用桌面端的自定义 Agent 入口。完整适配器和能力矩阵见[桌面端指南](desktop/README.md)。
请只使用官方 Release 下载的产物。
## 快速开始
<details>
<summary><strong>从源码运行</strong></summary>
### Apple 芯片 macOS 预览版
需要 Node.js 22.12 或更高版本以及 npm。
从[官方 V1.0.4 预发布页](https://github.com/Ryder-MHumble/Meldwork/releases/tag/Meldwork-V1.0.4)下载 [`Meldwork-0.1.4-arm64.dmg`](https://github.com/Ryder-MHumble/Meldwork/releases/download/Meldwork-V1.0.4/Meldwork-0.1.4-arm64.dmg),将 Meldwork 移入“应用程序”,并至少安装一个受支持的本地 Agent CLI。该预发布版使用 ad-hoc 临时签名且未公证;首次启动时,macOS 可能要求你在“系统设置 -> 隐私与安全性”中选择“仍要打开 / Open Anyway”。
~~~bash
### 从源码运行
前置条件:Node.js `22.12+` 和 npm。
```bash
npm --prefix frontend ci
npm --prefix desktop ci
npm --prefix desktop run dev
~~~
```
分发前运行:
分发构建前运行:
~~~bash
```bash
npm --prefix frontend test
npm --prefix frontend run build
npm --prefix frontend run build:desktop
npm --prefix desktop test
~~~
```
</details>
## 当前边界
## 信任边界
Meldwork 是本地 Electron 应用,不是托管式 Agent Fleet,也不是通用 Agent 框架。当前预览版不提供远程、云端或频道 Agent 执行,不提供自动选人组队、企业 SSO/RBAC/治理或 Outcome Network。Local-first 不等于完全离线:选定的 Agent 仍可能把 Prompt、附件或 Skill(技能)发送到其配置的 Provider(模型服务商)。工作区写入是可选的工作流控制,不是操作系统级沙箱。
- Meldwork 会话和编排状态保存在本地。
- 模型请求仍然按照你选择的 Agent 和 Provider 发出;Local-first 不等于所有模型离线运行。
- 兼容环境使用操作系统安全存储保存凭据。
- 工作目录写入必须显式开启。
- Renderer 不暴露 raw chain-of-thought、可执行路径、Secret 或不受限命令输出。
- Cloud、Channel、自动选人组队、企业治理和 Outcome Network 不是当前发布承诺。
## 文档
公开技术边界见[架构](architecture.md)和 [Agent Connector 契约](docs/agent-connector-sdk.md)。
- [架构与产品边界](architecture.md)
- [桌面端设置与 Agent 矩阵](desktop/README.md)
- [Agent Connector SDK](docs/agent-connector-sdk.md)
- [AI 可发现性索引](docs/ai-discoverability.md)
- [贡献指南](CONTRIBUTING.md)
- [安全策略](SECURITY.md)
## 许可证
Meldwork 采用 [Meldwork 非商用源码许可](LICENSE)。你可以为非商用目的查看、运行、修改和分享;商业使用需要事先书面许可,详见 [COMMERCIAL_USE.md](COMMERCIAL_USE.md)。
Meldwork 采用 [Meldwork 非商用源码许可 1.0](LICENSE)。你可以出于非商业目的查看、运行、修改和分享。商业使用需要事先书面许可,详见 [COMMERCIAL_USE.md](COMMERCIAL_USE.md)。
+3 -3
View File
@@ -1,13 +1,13 @@
{
"name": "meldwork-desktop",
"version": "0.1.3",
"version": "0.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meldwork-desktop",
"version": "0.1.3",
"license": "SEE LICENSE IN LICENSE",
"version": "0.1.4",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.23.0",
"@streamparser/json": "0.0.22",
+2 -2
View File
@@ -1,10 +1,10 @@
{
"name": "meldwork-desktop",
"version": "0.1.3",
"version": "0.1.4",
"private": true,
"description": "A local-first desktop workspace for multi-Agent collaboration",
"author": "Ryder-MHumble",
"license": "SEE LICENSE IN LICENSE",
"license": "Apache-2.0",
"engines": {
"node": ">=22.12.0"
},
+1 -1
View File
@@ -1 +1 @@
{"manifest":{"connectorId":"external.local-echo-sample","connectorVersion":"1.0.0","credentials":{"mode":"none","slots":[]},"description":"Local SDK sample that returns text without delegating to a built-in Agent.","domains":["general"],"eventProtocolVersion":1,"eventTypes":["Completed","Failed","Cancelled"],"inputTypes":["text"],"invocation":{"recipeId":"sdk.local-echo.v1"},"kind":"agent","label":"Local Echo Connector Sample","license":"LicenseRef-Meldwork-NC","manifestId":"connector-manifest-6ce79bf9cbe282e9f537a1b45d742cc507e21ade5ea2859dff9e007bd4f850db","outboundDestinations":[],"permissionModes":["read-only"],"recordType":"agent-connector-manifest","schemaVersion":1,"session":{"cancel":true,"checkpoint":false,"resume":true,"supported":true},"transport":{"protocol":"json","type":"cli"},"upstream":{"id":"meldwork-sdk","maxVersion":"1.0.0","minVersion":"1.0.0"},"usage":{"costMicros":false,"elapsedMs":false,"inputTokens":false,"outboundBytes":false,"outputTokens":false,"toolCalls":false}},"packageId":"connector-package-f4908cee8fa7acf5d17a403b39bd58cb8d4a1dfec3fbfb43c62c5727ee793cd4","provider":{"config":{},"id":"sdk.local-echo.v1"},"publisher":{"id":"meldwork","name":"Meldwork"},"recordType":"agent-connector-package","schemaVersion":1}
{"manifest":{"connectorId":"external.local-echo-sample","connectorVersion":"1.0.0","credentials":{"mode":"none","slots":[]},"description":"Local SDK sample that returns text without delegating to a built-in Agent.","domains":["general"],"eventProtocolVersion":1,"eventTypes":["Completed","Failed","Cancelled"],"inputTypes":["text"],"invocation":{"recipeId":"sdk.local-echo.v1"},"kind":"agent","label":"Local Echo Connector Sample","license":"Apache-2.0","manifestId":"connector-manifest-6ce79bf9cbe282e9f537a1b45d742cc507e21ade5ea2859dff9e007bd4f850db","outboundDestinations":[],"permissionModes":["read-only"],"recordType":"agent-connector-manifest","schemaVersion":1,"session":{"cancel":true,"checkpoint":false,"resume":true,"supported":true},"transport":{"protocol":"json","type":"cli"},"upstream":{"id":"meldwork-sdk","maxVersion":"1.0.0","minVersion":"1.0.0"},"usage":{"costMicros":false,"elapsedMs":false,"inputTokens":false,"outboundBytes":false,"outputTokens":false,"toolCalls":false}},"packageId":"connector-package-f4908cee8fa7acf5d17a403b39bd58cb8d4a1dfec3fbfb43c62c5727ee793cd4","provider":{"config":{},"id":"sdk.local-echo.v1"},"publisher":{"id":"meldwork","name":"Meldwork"},"recordType":"agent-connector-package","schemaVersion":1}
@@ -210,6 +210,15 @@ function normalizeExternalRunRef(value) {
return EXTERNAL_RUN_REF.test(ref) ? ref : ''
}
function terminalAuthenticationDiagnostic(value) {
const text = String(value || '').replace(/\s+/g, ' ').trim()
if (!text || text.length > 2000) return ''
return /^HTTP\s+(?:401|403)\s*:\s*(?:(?:unauthorized|forbidden)\b|(?:invalid|expired|missing)\b[\s\S]*(?:token|credential|api[ _-]?key|auth))/i
.test(text)
? text
: ''
}
function failureCategory(code) {
const value = String(code || '')
if (value === 'LOCAL_AGENT_SESSION_INVALID') return 'session'
@@ -299,6 +308,10 @@ function requireTerminalAgentResult(result) {
failure,
)
}
const authenticationDiagnostic = terminalAuthenticationDiagnostic(normalized.text)
if (authenticationDiagnostic) {
throw agentRuntimeError('LOCAL_AGENT_AUTH_REQUIRED', authenticationDiagnostic)
}
return normalized
}
@@ -332,4 +345,5 @@ module.exports = {
normalizeOutcome,
outcomeForAcpStopReason,
requireTerminalAgentResult,
terminalAuthenticationDiagnostic,
}
+3 -9
View File
@@ -745,7 +745,7 @@ function publicSessionRef(value, childEnv) {
return safe.includes('[redacted]') ? '' : safe
}
async function runAcpTurn(runtime, prompt, options, spec, profile, persistent) {
async function runAcpTurn(runtime, prompt, options, spec, profile) {
if (runtime.closed || runtime.failureError) {
throw runtime.failureError || agentExecutionError('LOCAL_AGENT_PROCESS_FAILED')
}
@@ -812,9 +812,6 @@ async function runAcpTurn(runtime, prompt, options, spec, profile, persistent) {
try {
if (!protocolSessionRef) {
if (persistent && options.sessionRef) {
throw agentExecutionError('LOCAL_AGENT_SESSION_INVALID')
}
if (spec.acpSessionStrategy !== 'new' && options.sessionRef) {
protocolSessionRef = String(options.sessionRef)
await operation(runtime.connection.resumeSession({
@@ -955,9 +952,6 @@ async function runPersistentAcpAgent(agent, prompt, workdir, options, spec, prof
runtime = null
if (options.sessionRef) throw agentExecutionError('LOCAL_AGENT_SESSION_INVALID')
}
if (!runtime && options.sessionRef) {
throw agentExecutionError('LOCAL_AGENT_SESSION_INVALID')
}
if (!runtime) {
if (shutdownGeneration !== acpShutdownGeneration) {
throw agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED')
@@ -969,7 +963,7 @@ async function runPersistentAcpAgent(agent, prompt, workdir, options, spec, prof
}
runtime.active = true
try {
return await runAcpTurn(runtime, prompt, options, spec, profile, true)
return await runAcpTurn(runtime, prompt, options, spec, profile)
} catch (error) {
acpSessionRuntimes.delete(key)
await closeAcpRuntime(runtime)
@@ -1003,7 +997,7 @@ async function runDisposableAcpAgent(agent, prompt, workdir, options, spec, prof
throw agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED')
}
acpDisposableRuntimes.add(runtime)
return await runAcpTurn(runtime, prompt, options, spec, profile, false)
return await runAcpTurn(runtime, prompt, options, spec, profile)
} finally {
if (runtime) {
acpDisposableRuntimes.delete(runtime)
+2
View File
@@ -15,6 +15,7 @@ const {
parseMimoOutput,
parseOpenCodeOutput,
parseOpenCodeReviewOutput,
parsePiOutput,
parseWorkBuddyOutput,
readHermesFinalResponse,
readHermesMessageWatermark,
@@ -500,6 +501,7 @@ module.exports = {
parseMimoOutput,
parseOpenCodeOutput,
parseOpenCodeReviewOutput,
parsePiOutput,
parseWorkBuddyOutput,
prepareCommand,
readHermesFinalResponse,
@@ -13,6 +13,7 @@ const {
parseMimoOutput,
parseOpenCodeOutput,
parseOpenCodeReviewOutput,
parsePiOutput,
parseWorkBuddyOutput,
readHermesFinalResponse,
readHermesMessageWatermark,
@@ -54,6 +55,7 @@ module.exports = {
parseMimoOutput,
parseOpenCodeOutput,
parseOpenCodeReviewOutput,
parsePiOutput,
parseWorkBuddyOutput,
readHermesFinalResponse,
readHermesMessageWatermark,
@@ -49,7 +49,7 @@ const SAMPLE_AGENT_CONNECTOR_MANIFEST = createAgentConnectorManifest({
},
outboundDestinations: [],
credentials: { mode: 'none', slots: [] },
license: 'LicenseRef-Meldwork-NC',
license: 'Apache-2.0',
})
const SAMPLE_CREDENTIAL_AGENT_CONNECTOR_MANIFEST = createAgentConnectorManifest({
connectorId: 'external.codex-provider-sample',
@@ -82,7 +82,7 @@ const SAMPLE_CREDENTIAL_AGENT_CONNECTOR_MANIFEST = createAgentConnectorManifest(
mode: 'credential-ref',
slots: [{ slotId: 'openai-api-key', type: 'api-key', required: true }],
},
license: 'LicenseRef-Meldwork-NC',
license: 'Apache-2.0',
})
const APPROVED_AGENT_CONNECTOR_MANIFESTS = Object.freeze([
SAMPLE_AGENT_CONNECTOR_MANIFEST,
@@ -117,7 +117,7 @@ const SAMPLE_LOCAL_ECHO_AGENT_CONNECTOR_MANIFEST = createAgentConnectorManifest(
},
outboundDestinations: [],
credentials: { mode: 'none', slots: [] },
license: 'LicenseRef-Meldwork-NC',
license: 'Apache-2.0',
})
const SAMPLE_LOCAL_ECHO_AGENT_CONNECTOR_PACKAGE = createAgentConnectorPackage({
publisher: { id: 'meldwork', name: 'Meldwork' },
@@ -9,7 +9,7 @@ const V4_TEMPLATES = new Set(['concurrent-batch', 'discussion'])
const V4_WORKFLOWS = new Set(['manual', 'auto', 'concurrent-batch', 'discussion'])
const V4_PHASES = new Set([
'prepare', 'dispatch', 'running', 'reconcile',
'proposal', 'challenge', 'coordination', 'work', 'synthesis', 'verification',
'proposal', 'discussion', 'challenge', 'coordination', 'work', 'synthesis', 'verification',
'commit', 'committed', 'completed', 'failed', 'stopped', 'human-gate',
])
const V4_SLOT_STATUSES = new Set([
@@ -60,7 +60,7 @@ const MESSAGE_ID = /^message-[a-f0-9]{64}$/u
const EVIDENCE_ID = /^evidence-[a-f0-9]{64}$/u
const ORCHESTRATION_FIELDS = new Set([
'version', 'workflow', 'template', 'phase', 'batchId', 'round',
'version', 'workflow', 'template', 'phase', 'discussionStyle', 'batchId', 'round',
'currentKind', 'currentKinds', 'pendingKinds', 'activeKinds',
'successfulKinds', 'agreementKinds', 'attachmentRecipients',
'totalSuccesses', 'terminalFailureOccurred', 'collaboration', 'taskGraph',
@@ -1784,6 +1784,14 @@ function parseOrchestrationV4(input, options = {}) {
}
const { workflow, template } = normalizeWorkflow(input.workflow, input.template)
const phase = phaseForTemplate(template, String(input.phase || ''))
const discussionStyle = hasOwn(input, 'discussionStyle')
? String(input.discussionStyle || '')
: ''
if (discussionStyle && (
workflow !== 'auto'
|| template !== 'discussion'
|| !['sequential', 'agent-led'].includes(discussionStyle)
)) fail('ORCHESTRATION_V4_SCHEMA_INVALID')
const batchId = cleanId(input.batchId)
const snapshotHash = String(input.snapshotHash || '')
if (!batchId || !SHA256.test(snapshotHash)) fail('ORCHESTRATION_V4_SCHEMA_INVALID')
@@ -2017,6 +2025,7 @@ function parseOrchestrationV4(input, options = {}) {
workflow,
template,
phase,
...(discussionStyle ? { discussionStyle } : {}),
batchId,
currentKinds,
activeKinds,
@@ -252,6 +252,10 @@ function normalizeContextStats(input) {
const value = String(input[field] || '')
if (SHA256.test(value)) context[field] = value
}
const operationId = cleanId(input.operationId)
if (operationId) context.operationId = operationId
const snapshotHash = String(input.snapshotHash || '')
if (SHA256.test(snapshotHash)) context.snapshotHash = snapshotHash
if (input.sessionRotated === true) context.sessionRotated = true
const contextPackId = normalizeContextPackId(input.contextPackId)
if (contextPackId) context.contextPackId = contextPackId
+1 -1
View File
@@ -28,7 +28,7 @@ const CONTEXT_FIELDS = new Set([
'includedCount', 'omittedCount', 'charCount', 'sessionRotated', 'externalRunRef',
'contextMode', 'promptChars', 'contextPackId', 'contextPackState', 'deliveryRecordIds',
'sessionProvenance', 'outcomeRefs', 'sourceCount', 'sourceHash', 'promptBytes',
'promptHash', 'wirePayloadBytes', 'wirePayloadHash',
'promptHash', 'wirePayloadBytes', 'wirePayloadHash', 'operationId', 'snapshotHash',
'connector', 'connectorEventState',
])
+18 -6
View File
@@ -42,7 +42,7 @@ const CONTEXT_FIELDS = new Set([
'includedCount', 'omittedCount', 'charCount', 'sessionRotated', 'externalRunRef',
'contextMode', 'promptChars', 'contextPackId', 'contextPackState', 'deliveryRecordIds',
'sessionProvenance', 'outcomeRefs', 'sourceCount', 'sourceHash', 'promptBytes',
'promptHash', 'wirePayloadBytes', 'wirePayloadHash',
'promptHash', 'wirePayloadBytes', 'wirePayloadHash', 'operationId', 'snapshotHash',
'connector', 'connectorEventState',
])
const CONTEXT_PACK_STATES = new Set(['captured', 'legacy-unavailable'])
@@ -62,7 +62,7 @@ const CONTINUATION_FIELDS = new Set([
])
const CONTINUATION_INVOCATION_FIELDS = ['phase', 'slotId', 'operationId', 'snapshotHash']
const CONTINUATION_V4_PHASES = new Set([
'proposal', 'challenge', 'work', 'synthesis', 'verification',
'proposal', 'discussion', 'challenge', 'work', 'synthesis', 'verification',
])
const CONTINUATION_GATE_TYPES = new Set(['permission', 'budget', 'decision', 'retry', 'input'])
const CONTINUATION_RESUME_KINDS = new Set([
@@ -75,8 +75,9 @@ const ACTIVE_CONTINUATION_STATES = new Set(['pending', 'ready', 'resuming'])
const ORCHESTRATION_FIELDS = new Set([
'version', 'workflow', 'currentKind', 'pendingKinds', 'activeKinds',
'successfulKinds', 'agreementKinds', 'attachmentRecipients',
'totalSuccesses', 'terminalFailureOccurred', 'collaboration', 'template', 'taskGraph',
'phase', 'batchId', 'round', 'currentKinds', 'snapshotHash', 'snapshot', 'plan',
'totalSuccesses', 'terminalFailureOccurred', 'executionSequence',
'collaboration', 'template', 'taskGraph',
'phase', 'discussionStyle', 'batchId', 'round', 'currentKinds', 'snapshotHash', 'snapshot', 'plan',
'slots', 'deliveryWatermarks', 'deliveryState', 'commitState', 'challengeBindings', 'synthesisBinding',
'synthesisRecovery', 'convergence', 'coordinationPlan', 'workReceipts', 'candidateCommit',
])
@@ -239,7 +240,7 @@ function normalizeAgentRun(input, parent, fallbackTimestamp) {
'includedCount', 'omittedCount', 'charCount', 'sessionRotated', 'externalRunRef',
'contextMode', 'promptChars', 'contextPackId', 'deliveryRecordIds', 'sessionProvenance', 'outcomeRefs',
'sourceCount', 'sourceHash', 'promptBytes', 'promptHash', 'wirePayloadBytes', 'wirePayloadHash',
'connector', 'connectorEventState',
'operationId', 'snapshotHash', 'connector', 'connectorEventState',
].some(key => hasOwn(input.context, key))
const rawEvents = Array.isArray(input.events) ? input.events : []
const rawSourceIds = Array.isArray(input.sourceMessageIds) ? input.sourceMessageIds : []
@@ -489,8 +490,9 @@ function normalizeOrchestration(input) {
const agreementKinds = normalizeKinds(input.agreementKinds)
const attachmentRecipients = normalizeKinds(input.attachmentRecipients)
const totalSuccesses = boundedNumber(input.totalSuccesses, 0, 1000000)
const executionSequence = boundedNumber(input.executionSequence, 0, 1000000)
const v4OnlyFields = [
'phase', 'batchId', 'round', 'currentKinds', 'snapshotHash', 'snapshot', 'plan',
'phase', 'discussionStyle', 'batchId', 'round', 'currentKinds', 'snapshotHash', 'snapshot', 'plan',
'slots', 'deliveryWatermarks', 'deliveryState', 'commitState', 'challengeBindings',
'synthesisBinding', 'synthesisRecovery', 'convergence', 'candidateCommit',
]
@@ -498,6 +500,10 @@ function normalizeOrchestration(input) {
|| (version === 1 && hasOwn(input, 'collaboration'))
|| (version === 2 && workflow !== 'auto')
|| (version < 3 && (hasOwn(input, 'template') || hasOwn(input, 'taskGraph')))
|| (hasOwn(input, 'executionSequence')
&& (!Number.isInteger(input.executionSequence)
|| input.executionSequence < 0
|| input.executionSequence > 1000000))
|| (version < 4 && v4OnlyFields.some(field => hasOwn(input, field)))
|| (version === 3 && (workflow !== 'auto' || input.template !== 'task-graph'))) {
return undefined
@@ -529,6 +535,7 @@ function normalizeOrchestration(input) {
attachmentRecipients,
totalSuccesses,
terminalFailureOccurred: input.terminalFailureOccurred === true,
...(hasOwn(input, 'executionSequence') ? { executionSequence } : {}),
...(version >= 2 ? { collaboration } : {}),
...(version === 3 ? { template: 'task-graph', taskGraph } : {}),
}
@@ -694,6 +701,11 @@ function hasValidStoredRecordShape(input) {
&& (typeof agentRun.context[field] !== 'string'
|| !SHA256.test(agentRun.context[field]))) return false
}
if (hasOwn(agentRun.context, 'operationId')
&& cleanId(agentRun.context.operationId) !== agentRun.context.operationId) return false
if (hasOwn(agentRun.context, 'snapshotHash')
&& (typeof agentRun.context.snapshotHash !== 'string'
|| !SHA256.test(agentRun.context.snapshotHash))) return false
if (hasOwn(agentRun.context, 'contextPackId')
&& normalizeContextPackId(agentRun.context.contextPackId)
!== agentRun.context.contextPackId) return false
@@ -83,7 +83,9 @@ function agentReturnedMediaProviderFailure(text) {
}
const OPERATION_ID = /^[A-Za-z0-9._:-]{1,120}$/
const SHA256 = /^[a-f0-9]{64}$/
const V4_PHASES = new Set(['proposal', 'challenge', 'work', 'synthesis', 'verification'])
const V4_PHASES = new Set([
'proposal', 'discussion', 'challenge', 'work', 'synthesis', 'verification',
])
const V4_RECEIPT_MARKER_PREFIX = '[[MELDWORK_COLLABORATION'
const V4_RECEIPT_MARKER = /\s*\[\[MELDWORK_COLLABORATION(?:_JSON)?:([\s\S]*?)\]\]/i
const V4_RECEIPT_BLOCK = /\s*\[\[MELDWORK_COLLABORATION(?:_JSON)?\]\]([\s\S]*?)\[\[\/MELDWORK_COLLABORATION(?:_JSON)?\]\]/i
@@ -326,7 +328,8 @@ function attachInvocationFailure(error, input) {
}
function isolatedInvocationContext(context) {
if (!['isolated', 'frozen'].includes(context?.sessionPolicy)) return null
if (!['isolated', 'frozen'].includes(context?.sessionPolicy)
&& !(context?.v4 === true && typeof context.promptOverride === 'string')) return null
const promptOverride = typeof context.promptOverride === 'string'
? context.promptOverride
: ''
@@ -339,7 +342,7 @@ function isolatedInvocationContext(context) {
return {
promptOverride,
contextPackId,
policy: context.sessionPolicy,
policy: ['isolated', 'frozen'].includes(context?.sessionPolicy) ? context.sessionPolicy : '',
}
}
@@ -712,7 +715,12 @@ class LocalWorkspaceAgentInvocation {
&& !resumedConnectorGate
? context.resumedGate
: null
const sessionNeedsRotation = sessionRef && shouldRotateSession(sessionMeta)
const preserveV4TaskSession = context.v4 === true
&& group.conversationType !== 'direct'
&& Boolean(taskId)
const sessionNeedsRotation = sessionRef
&& shouldRotateSession(sessionMeta)
&& !preserveV4TaskSession
if (resumedPermission) {
const requestHash = sha256(canonicalJson(resumedPermission.request))
const persistedBinding = {
@@ -1172,6 +1180,12 @@ class LocalWorkspaceAgentInvocation {
...(Number.isInteger(context.executionSequence)
? { executionSequence: context.executionSequence }
: {}),
...(v4AgentSlotBinding
? {
operationId: v4AgentSlotBinding.operationId,
snapshotHash: v4AgentSlotBinding.snapshotHash,
}
: {}),
...runtimeContext,
...deliveryContext,
...connectorContext,
@@ -1261,12 +1275,14 @@ class LocalWorkspaceAgentInvocation {
const buildPrompt = (afterKind, contextPackage) => isolated || frozen
? v4Prompt
: [
this.promptFor(
group, kind, context.completionPolicy === 'typed' ? 'manual' : mode,
threadRootId, context.skillHints || [],
context.knowledgeBaseHints || [], afterKind, contextPackage, promptMode,
activeRun?.unlimitedRounds === true,
),
context.v4 === true && v4Prompt
? v4Prompt
: this.promptFor(
group, kind, context.completionPolicy === 'typed' ? 'manual' : mode,
threadRootId, context.skillHints || [],
context.knowledgeBaseHints || [], afterKind, contextPackage, promptMode,
activeRun?.unlimitedRounds === true,
),
stagedAgentInputPrompt(stagedInputs),
generatedMedia
? `Meldwork generated and will attach ${generatedMedia.filename}. Confirm the delivered media briefly; do not claim a different file was created.`
@@ -70,6 +70,7 @@ const {
terminalRunStatusForReason,
unauthorizedFailure,
} = require('./local-workspace-auto-runner-support.cjs')
const { traceCapsuleFromAgentRun } = require('../runs/run-harness-normalization.cjs')
const { v4OutcomeMethods } = require('./local-workspace-auto-runner-v4-outcomes.cjs')
class LocalWorkspaceAutoRunner {
@@ -1473,14 +1474,16 @@ class LocalWorkspaceAutoRunner {
)
}
v4OperationId(controller, kind, phase, slotId = '') {
v4OperationId(
controller, kind, phase, slotId = '', round = controller.currentRound || 0,
) {
const digest = createHash('sha256').update(JSON.stringify([
controller.runId,
controller.taskId,
kind,
slotId,
phase,
controller.currentRound || 0,
round,
])).digest('hex')
return `operation-${digest}`
}
@@ -2037,7 +2040,7 @@ class LocalWorkspaceAutoRunner {
}
v4CheckpointRole(phase, slot, coordinationPlan, workAssignment = null) {
if (['proposal', 'challenge'].includes(phase)) return 'participant'
if (['proposal', 'discussion', 'challenge'].includes(phase)) return 'participant'
const assignment = phase === 'work'
? (workAssignment || this.v4CoordinationAssignment(coordinationPlan, slot.agentKind))
: (phase === 'synthesis'
@@ -2052,7 +2055,7 @@ class LocalWorkspaceAutoRunner {
}
v4PromptRole(phase, kind, coordinationPlan, workAssignment = null) {
if (['proposal', 'challenge'].includes(phase)) return 'participant'
if (['proposal', 'discussion', 'challenge'].includes(phase)) return 'participant'
if (phase === 'verification') {
if (!coordinationPlan?.verifierKinds?.includes(kind)) {
throw new Error('LOCAL_RUN_V4_COORDINATION_PLAN_INVALID')
@@ -2425,6 +2428,443 @@ class LocalWorkspaceAutoRunner {
].filter(Boolean).join('\n\n')
}
v4NaturalRouteDecision(text, activeKinds) {
const participants = Array.isArray(activeKinds) ? activeKinds : []
const finalLine = String(text || '').split(/\r?\n/u)
.map(line => line.trim())
.filter(Boolean)
.at(-1) || ''
if (!finalLine || !participants.length) return { status: 'none', kinds: [] }
const selected = new Set()
const mentionPattern = /@([A-Za-z0-9][A-Za-z0-9_-]{0,39})/gu
let cursor = 0
for (const match of finalLine.matchAll(mentionPattern)) {
const separator = finalLine.slice(cursor, match.index)
if (separator.includes('@') || !/^[\s\p{P}]*$/u.test(separator)) {
return { status: 'invalid', kinds: [] }
}
const kind = match[1]
if (!participants.includes(kind)) return { status: 'invalid', kinds: [] }
selected.add(kind)
cursor = match.index + match[0].length
}
const tail = finalLine.slice(cursor)
if (!selected.size) {
return finalLine.includes('@')
? { status: 'invalid', kinds: [] }
: { status: 'none', kinds: [] }
}
if (tail.includes('@') || !/^[\s\p{P}]*$/u.test(tail)) {
return { status: 'invalid', kinds: [] }
}
return { status: 'valid', kinds: participants.filter(kind => selected.has(kind)) }
}
v4MentionedNextKinds(text, activeKinds) {
return this.v4NaturalRouteDecision(text, activeKinds).kinds
}
v4NaturalMessageMatchesBinding(message, controller) {
const phase = String(message?.trace?.phase || '')
const kind = String(message?.agentKind || '')
const round = Number(message?.trace?.round) || 0
if (!['proposal', 'discussion'].includes(phase) || !kind || round < 1) return false
if (message.trace?.runId !== controller.runId) return false
const slot = controller.orchestration?.slots?.find(candidate => candidate.agentKind === kind)
const snapshotHash = String(controller.orchestration?.snapshotHash || '')
if (!slot?.slotId || !snapshotHash) return false
const operationPhase = phase === 'discussion' ? `discussion:${round}` : phase
const expectedOperationId = this.v4OperationId(
controller, kind, operationPhase, slot.slotId, round,
)
return message.trace?.context?.operationId === expectedOperationId
&& message.trace?.context?.snapshotHash === snapshotHash
}
v4NaturalThreadTranscript(group, threadRootId, options = {}) {
const rounds = Array.isArray(options.rounds) ? new Set(options.rounds) : null
const blocks = this.state().messages.filter(message => (
message.groupId === group.id
&& message.threadRootId === threadRootId
&& message.role === 'agent'
&& message.agentKind
&& (!rounds || rounds.has(Number(message.trace?.round) || 0))
&& (!options.controller || this.v4NaturalMessageMatchesBinding(
message, options.controller,
))
)).map(message => [
`Round ${Number(message.trace?.round) || 0} - @${message.agentKind}`,
String(message.content || '').trim(),
].filter(Boolean).join('\n'))
return this.v4SanitizeDeliveryText(blocks.join('\n\n'), Number.MAX_SAFE_INTEGER)
}
v4NaturalRecoveredPhaseResult(group, controller, threadRootId, phase, kind, slot) {
if (!this.naturalAgentResponses || !slot || controller.currentRound < 1) return null
if (!['proposal', 'discussion'].includes(phase)) return null
const operationId = String(slot.operationId || '')
const snapshotHash = String(slot.snapshotHash || '')
if (!operationId || !snapshotHash) return null
const matchesBinding = context => (
context?.operationId === operationId && context?.snapshotHash === snapshotHash
)
const scopedMessages = [...this.state().messages].reverse().filter(message => (
message.groupId === group.id
&& message.threadRootId === threadRootId
&& message.role === 'agent'
&& message.agentKind === kind
&& message.trace?.runId === controller.runId
&& message.trace?.phase === phase
&& Number(message.trace?.round) === controller.currentRound
))
if (scopedMessages.some(message => !matchesBinding(message.trace?.context))) {
throw new Error('LOCAL_RUN_COLLABORATION_SCOPE_INVALID')
}
const existingMessage = scopedMessages.find(message => matchesBinding(message.trace?.context))
let content = String(existingMessage?.content || '')
let trace = existingMessage?.trace || null
let metadata = existingMessage ? {
...(existingMessage.elapsedMs != null ? { elapsedMs: existingMessage.elapsedMs } : {}),
...(Array.isArray(existingMessage.toolCalls) ? { toolCalls: existingMessage.toolCalls } : {}),
...(Array.isArray(existingMessage.attachments)
? { attachments: existingMessage.attachments } : {}),
...(existingMessage.responseVersionRootId
? { responseVersionRootId: existingMessage.responseVersionRootId } : {}),
trace,
} : null
if (!content || !trace?.agentRunId) {
const scopedRuns = [...(controller.harness?.snapshot?.() || [])].reverse().filter(run => (
run.kind === kind
&& run.round === controller.currentRound
&& run.status === 'completed'
&& String(run.output || '')
))
if (scopedRuns.some(run => !matchesBinding(run.context))) {
throw new Error('LOCAL_RUN_COLLABORATION_SCOPE_INVALID')
}
const recoveredRun = scopedRuns.find(run => matchesBinding(run.context))
if (!recoveredRun) return null
content = String(recoveredRun.output)
trace = traceCapsuleFromAgentRun(recoveredRun, {
runId: controller.runId,
status: recoveredRun.status,
phase,
})
if (!trace?.agentRunId) return null
metadata = { trace }
}
return {
text: content,
pendingMessage: {
groupId: group.id,
role: 'agent',
content,
agentKind: kind,
threadRootId,
system: null,
metadata,
},
collaboration: null,
outcomeRefs: trace.context?.outcomeRefs || {},
operationId: slot.operationId,
consensus: false,
}
}
v4NaturalRecoveredDiscussionResult(group, controller, threadRootId, kind, slot) {
return this.v4NaturalRecoveredPhaseResult(
group, controller, threadRootId, 'discussion', kind, slot,
)
}
v4NaturalPhasePrompt(group, kind, phase, snapshot, receiptRecords, activeKinds, options = {}) {
const transcript = String(options.transcript || '')
const agentList = activeKinds.map(agentKind => `@${agentKind}`).join(', ')
return [
v4Prompt({
group, kind, phase, snapshot, role: 'participant',
skillHints: options.skillHints, naturalResponse: true,
}),
transcript ? `Completed peer turns:\n${transcript}` : '',
phase === 'discussion' && options.allowRouting === true
? [
'Decide which Agent or Agents should contribute next based on the discussion and the work still needed.',
`The final non-empty line may contain only the exact Agent mentions that should respond next: ${agentList}.`,
'Use one mention for a single next Agent or multiple mentions for a concurrent batch.',
'End without any Agent mention only when you accept the current result and made no substantive change. If you made a substantive change, mention at least one different Agent for review.',
].join('\n')
: '',
].filter(Boolean).join('\n\n')
}
v4NaturalDiscussionOperationIds(controller, slots, kinds, round) {
return new Map(kinds.map((kind) => {
const slot = slots.find(candidate => candidate.agentKind === kind)
return [kind, this.v4OperationId(
controller, kind, `discussion:${round}`, slot?.slotId || '', round,
)]
}))
}
v4NaturalDiscussionRoundMessages(group, controller, threadRootId, round) {
return this.state().messages.filter(message => (
message.groupId === group.id
&& message.threadRootId === threadRootId
&& message.role === 'agent'
&& message.trace?.runId === controller.runId
&& message.trace?.phase === 'discussion'
&& Number(message.trace?.round) === round
&& this.v4NaturalMessageMatchesBinding(message, controller)
))
}
v4NaturalRoundHasPeerConfirmation(
group, controller, threadRootId, round, activeKinds,
) {
if (round <= 2) return false
const currentMessages = this.v4NaturalDiscussionRoundMessages(
group, controller, threadRootId, round,
)
if (!currentMessages.length) return false
const currentDecisions = currentMessages.map(message => (
this.v4NaturalRouteDecision(message.content, activeKinds)
))
if (currentDecisions.some(decision => decision.status !== 'none')) return false
const previousMessages = this.v4NaturalDiscussionRoundMessages(
group, controller, threadRootId, round - 1,
)
if (!previousMessages.length) return false
const previousDecisions = previousMessages.map(message => ({
agentKind: message.agentKind,
decision: this.v4NaturalRouteDecision(message.content, activeKinds),
}))
const peerSelected = currentMessages.some(message => previousDecisions.some(previous => (
previous.agentKind !== message.agentKind
&& previous.decision.status === 'valid'
&& previous.decision.kinds.includes(message.agentKind)
)))
if (peerSelected) return true
return previousDecisions.every(previous => previous.decision.status === 'none')
&& currentMessages.some(message => (
previousMessages.every(previous => previous.agentKind !== message.agentKind)
))
}
v4NaturalConfirmationKind(activeKinds, currentKinds) {
const current = new Set(currentKinds)
return activeKinds.find(kind => !current.has(kind)) || ''
}
async runV4SequentialDiscussion(group, controller, threadRootId, context, input) {
const {
targetKinds, activeKinds: initialActiveKinds, writerKind, batchId,
snapshot, snapshotRecord, snapshotHash, slots: initialSlots, receiptRecords,
checkpointPhase, removePhaseFailures,
} = input
let activeKinds = [...initialActiveKinds]
let slots = initialSlots
let round = Math.max(1, Number(controller.currentRound) || 1)
let pendingKinds = controller.orchestration?.discussionStyle === 'sequential'
&& controller.orchestration?.phase === 'discussion'
&& controller.orchestration?.round === round
? (controller.orchestration.pendingKinds || []).filter(kind => activeKinds.includes(kind))
: [...activeKinds]
if (!pendingKinds.length && controller.orchestration?.phase === 'discussion') {
if (!controller.unlimitedRounds && round >= (controller.maxRounds || 6)) return 'completed'
round += 1
pendingKinds = [...activeKinds]
}
while (!controller.signal.aborted) {
controller.currentRound = round
while (pendingKinds.length && !controller.signal.aborted) {
const kind = pendingKinds[0]
const remainingKinds = pendingKinds.slice(1)
const dispatch = await this.runV4Phase(group, controller, threadRootId, context, {
phase: 'discussion',
activeKinds: [kind],
targetKinds,
participantKinds: activeKinds,
writerKind,
batchId,
snapshot,
snapshotRecord,
snapshotHash,
slots,
receiptRecords,
challengeBindings: null,
coordinationPlan: null,
workReceipts: [],
operationIds: this.v4NaturalDiscussionOperationIds(
controller, slots, [kind], round,
),
pendingKinds,
remainingKinds,
sessionPolicy: null,
disableDeliveryPrompt: true,
promptBuilder: ({ kind: promptKind }) => this.v4NaturalPhasePrompt(
group, promptKind, 'discussion', snapshot, receiptRecords, activeKinds, {
transcript: this.v4NaturalThreadTranscript(
group, threadRootId, { controller },
),
skillHints: context.rootSkillsByKind.get(promptKind) || [],
},
),
})
slots = dispatch.slots || slots
if (!dispatch.ok) {
if (controller.signal.aborted) return terminalRunStatusForReason(controller.stopReason)
const failedKinds = removePhaseFailures(dispatch)
activeKinds = activeKinds.filter(agentKind => !failedKinds.includes(agentKind))
if (!activeKinds.length) {
return dispatch.phasePendingMessages?.length ? 'partial' : 'failed'
}
}
pendingKinds = remainingKinds.filter(agentKind => activeKinds.includes(agentKind))
}
if (!controller.unlimitedRounds && round >= (controller.maxRounds || 6)) return 'completed'
round += 1
controller.currentRound = round
pendingKinds = [...activeKinds]
}
return terminalRunStatusForReason(controller.stopReason)
}
async runV4AgentLedDiscussion(group, controller, threadRootId, context, input) {
const {
targetKinds, activeKinds: initialActiveKinds, writerKind, batchId,
snapshot, snapshotRecord, snapshotHash, slots: initialSlots, receiptRecords,
latestFor, runPhase, checkpointPhase, removePhaseFailures, addRoundLimitNotice,
} = input
let activeKinds = [...initialActiveKinds]
let slots = initialSlots
let round = Math.max(1, Number(controller.currentRound) || 1)
const proposalCompleteBeforeRun = activeKinds.every(kind => Boolean(latestFor('proposal', kind)))
if (!proposalCompleteBeforeRun) {
controller.currentRound = 1
const proposal = await runPhase('proposal', activeKinds)
slots = proposal.slots || slots
if (!proposal.ok) {
const failedKinds = removePhaseFailures(proposal)
activeKinds = activeKinds.filter(kind => !failedKinds.includes(kind))
checkpointPhase('proposal', [], slots, null)
if (!activeKinds.length) return proposal.phasePendingMessages?.length ? 'partial' : 'failed'
}
}
const proposalComplete = activeKinds.every(kind => Boolean(latestFor('proposal', kind)))
if (!proposalComplete) {
return activeKinds.some(kind => Boolean(latestFor('proposal', kind))) ? 'partial' : 'failed'
}
if (!controller.unlimitedRounds && (controller.maxRounds || 6) <= 1) return 'completed'
let nextKinds = []
if (controller.orchestration?.phase === 'discussion') {
round = Math.max(2, Number(controller.orchestration.round) || 2)
nextKinds = (controller.orchestration.pendingKinds || [])
.filter(kind => activeKinds.includes(kind))
if (!nextKinds.length) {
const roundMessages = this.v4NaturalDiscussionRoundMessages(
group, controller, threadRootId, round,
)
nextKinds = [...new Set(roundMessages.flatMap((message) => {
const decision = this.v4NaturalRouteDecision(message.content, activeKinds)
return decision.status === 'valid' ? decision.kinds : []
}))]
if (!nextKinds.length) {
if (this.v4NaturalRoundHasPeerConfirmation(
group, controller, threadRootId, round, activeKinds,
)) return 'completed'
const confirmationKind = this.v4NaturalConfirmationKind(
activeKinds, roundMessages.map(message => message.agentKind),
)
if (!confirmationKind) return roundMessages.length ? 'partial' : 'failed'
nextKinds = [confirmationKind]
}
if (!controller.unlimitedRounds && round >= (controller.maxRounds || 6)) {
addRoundLimitNotice()
return 'round-limit'
}
round += 1
}
} else {
round = 2
nextKinds = activeKinds.slice(0, 1)
}
while (!controller.signal.aborted) {
controller.currentRound = round
const dispatch = await this.runV4Phase(group, controller, threadRootId, context, {
phase: 'discussion',
activeKinds: nextKinds,
targetKinds,
participantKinds: activeKinds,
writerKind,
batchId,
snapshot,
snapshotRecord,
snapshotHash,
slots,
receiptRecords,
challengeBindings: null,
coordinationPlan: null,
workReceipts: [],
operationIds: this.v4NaturalDiscussionOperationIds(
controller, slots, nextKinds, round,
),
pendingKinds: nextKinds,
remainingKinds: [],
sessionPolicy: null,
disableDeliveryPrompt: true,
promptBuilder: ({ kind }) => this.v4NaturalPhasePrompt(
group, kind, 'discussion', snapshot, receiptRecords, activeKinds, {
transcript: this.v4NaturalThreadTranscript(group, threadRootId, { controller }),
skillHints: context.rootSkillsByKind.get(kind) || [],
allowRouting: true,
},
),
})
slots = dispatch.slots || slots
if (!dispatch.ok) {
if (controller.signal.aborted) return terminalRunStatusForReason(controller.stopReason)
const failedKinds = removePhaseFailures(dispatch)
activeKinds = activeKinds.filter(kind => !failedKinds.includes(kind))
if (!activeKinds.length) return dispatch.phasePendingMessages?.length ? 'partial' : 'failed'
}
const roundMessages = this.v4NaturalDiscussionRoundMessages(
group, controller, threadRootId, round,
)
nextKinds = [...new Set(roundMessages.flatMap((message) => {
const decision = this.v4NaturalRouteDecision(message.content, activeKinds)
return decision.status === 'valid' ? decision.kinds : []
}))].filter(kind => activeKinds.includes(kind))
if (!nextKinds.length) {
if (this.v4NaturalRoundHasPeerConfirmation(
group, controller, threadRootId, round, activeKinds,
)) return 'completed'
const confirmationKind = this.v4NaturalConfirmationKind(
activeKinds, roundMessages.map(message => message.agentKind),
)
if (!confirmationKind) return roundMessages.length ? 'partial' : 'failed'
nextKinds = [confirmationKind]
}
if (!controller.unlimitedRounds && round >= (controller.maxRounds || 6)) {
addRoundLimitNotice()
return 'round-limit'
}
round += 1
controller.currentRound = round
}
return terminalRunStatusForReason(controller.stopReason)
}
async runV4NaturalDiscussion(group, controller, threadRootId, context, input) {
const style = controller.orchestration?.discussionStyle
|| controller.discussionStyle
|| 'sequential'
return style === 'agent-led'
? this.runV4AgentLedDiscussion(group, controller, threadRootId, context, input)
: this.runV4SequentialDiscussion(group, controller, threadRootId, context, input)
}
recordV4ReviewerFindings({ controller, phase, kind, result, verdict, summary,
reviewedArtifactId = '' }) {
if (!this.outcomeStore || !['challenge', 'verification'].includes(phase)
@@ -2793,6 +3233,9 @@ class LocalWorkspaceAutoRunner {
workflow: 'auto',
template: 'discussion',
phase,
discussionStyle: controller.discussionStyle
|| controller.orchestration?.discussionStyle
|| 'sequential',
batchId,
round: Math.max(0, Number(controller.currentRound) || 0),
currentKind: '',
@@ -2964,22 +3407,27 @@ class LocalWorkspaceAutoRunner {
const settled = await Promise.all(dispatches.map(async (dispatch) => {
const { kind, slot, context } = dispatch
try {
const invocation = await this.invokeWithUnauthorizedRecovery({
group,
kind,
controller,
activeKinds: participantKinds,
threadRootId,
context: {
...context,
onLeaseAcquired: (leaseState) => {
if (!leaseState?.agentRunId) throw new Error('LOCAL_RUN_PERSIST_FAILED')
slot.agentRunId = leaseState.agentRunId
slot.status = 'running'
checkpoint()
},
},
})
const recoveredResult = this.v4NaturalRecoveredPhaseResult(
group, controller, threadRootId, 'proposal', kind, slot,
)
const invocation = recoveredResult
? { result: recoveredResult }
: await this.invokeWithUnauthorizedRecovery({
group,
kind,
controller,
activeKinds: participantKinds,
threadRootId,
context: {
...context,
onLeaseAcquired: (leaseState) => {
if (!leaseState?.agentRunId) throw new Error('LOCAL_RUN_PERSIST_FAILED')
slot.agentRunId = leaseState.agentRunId
slot.status = 'running'
checkpoint()
},
},
})
if (controller.signal.aborted) {
slot.status = 'stopped'
slot.commitStatus = 'partial'
@@ -3280,7 +3728,9 @@ class LocalWorkspaceAutoRunner {
const synthesisAttempt = phase === 'synthesis'
? this.v4ActiveSynthesisAttempt(synthesisRecovery)
: null
const operationIds = synthesisAttempt
const operationIds = phaseInput.operationIds instanceof Map
? phaseInput.operationIds
: synthesisAttempt
? new Map([[synthesisAttempt.writerKind, synthesisAttempt.operationId]])
: (phase === 'work'
? new Map(activeKinds.map((kind) => {
@@ -3321,6 +3771,9 @@ class LocalWorkspaceAutoRunner {
slot.status = phase === 'synthesis' ? 'queued' : 'running'
}
}
const cursorPendingKinds = Array.isArray(phaseInput.pendingKinds)
? phaseInput.pendingKinds
: activeKinds
this.v4CheckpointPhase(group, controller, {
targetKinds,
phase,
@@ -3330,7 +3783,7 @@ class LocalWorkspaceAutoRunner {
slots: phaseSlots,
writerKind,
currentKinds: activeKinds,
pendingKinds: activeKinds,
pendingKinds: cursorPendingKinds,
participantKinds,
receipts: receiptRecords,
challengeBindings,
@@ -3351,6 +3804,7 @@ class LocalWorkspaceAutoRunner {
skillHints: context.rootSkillsByKind.get(kind) || [],
},
)
const sessionPolicy = this.naturalAgentResponses ? null : 'frozen'
return {
kind,
slot,
@@ -3360,7 +3814,7 @@ class LocalWorkspaceAutoRunner {
batchId,
slotId: slot.slotId,
snapshotHash,
sessionPolicy: 'frozen',
...(sessionPolicy ? { sessionPolicy } : {}),
promptOverride: prompt,
contextPackId: controller.contextPackId,
snapshotSourceMessageIds,
@@ -3439,27 +3893,39 @@ class LocalWorkspaceAutoRunner {
phase, kind, coordinationPlan, workAssignmentsByKind.get(kind) || null,
)
const extraContext = extraContextFor(kind)
const prompt = this.v4PhasePrompt(
group,
kind,
phase,
snapshot,
deliveryReceiptRecords,
role,
{
reviewTarget: phase === 'challenge'
? challengeBinding?.proposalKind || ''
: '',
assignedProposalText,
packageText: '',
extraContext,
snapshotHash,
skillHints: context.rootSkillsByKind.get(kind) || [],
},
)
const promptOptions = {
reviewTarget: phase === 'challenge'
? challengeBinding?.proposalKind || ''
: '',
assignedProposalText,
packageText: '',
extraContext,
snapshotHash,
skillHints: context.rootSkillsByKind.get(kind) || [],
}
const prompt = typeof phaseInput.promptBuilder === 'function'
? phaseInput.promptBuilder({ kind, slot, role, options: promptOptions })
: this.v4PhasePrompt(
group,
kind,
phase,
snapshot,
deliveryReceiptRecords,
role,
promptOptions,
)
const sessionPolicy = phaseInput.sessionPolicy === undefined
? 'frozen'
: phaseInput.sessionPolicy
let preparedDelivery = null
let item
const resumedSlotGate = resumedGateFor(slot)
const recoveredResult = phase === 'discussion'
? this.v4NaturalRecoveredDiscussionResult(
group, controller, threadRootId, kind, slot,
)
: null
if (recoveredResult) return { kind, invocation: { result: recoveredResult } }
try {
const invocation = await this.invokeWithUnauthorizedRecovery({
group,
@@ -3473,7 +3939,7 @@ class LocalWorkspaceAutoRunner {
batchId,
slotId: slot.slotId,
snapshotHash,
sessionPolicy: 'frozen',
...(sessionPolicy ? { sessionPolicy } : {}),
promptOverride: prompt,
contextPackId: controller.contextPackId,
snapshotSourceMessageIds,
@@ -3521,7 +3987,7 @@ class LocalWorkspaceAutoRunner {
slots: phaseSlots,
writerKind,
currentKinds: activeKinds,
pendingKinds: activeKinds,
pendingKinds: cursorPendingKinds,
participantKinds,
receipts: receiptRecords,
challengeBindings,
@@ -3531,26 +3997,30 @@ class LocalWorkspaceAutoRunner {
workAssignments,
})
},
v4PromptBuilder: (sessionBinding) => {
const built = this.v4DeliveryPrompt(group, controller, {
kind, phase, snapshot, receiptRecords: deliveryReceiptRecords,
role, targetKinds: participantKinds, slot,
options: {
reviewTarget: phase === 'challenge'
? challengeBinding?.proposalKind || ''
: '',
assignedProposalText,
extraContext,
...(['synthesis', 'verification'].includes(phase)
? { artifactContext: { coordinationPlan } }
: {}),
},
sessionBinding,
skillHints: context.rootSkillsByKind.get(kind) || [],
})
preparedDelivery = built.delivery
return built
},
...(phaseInput.disableDeliveryPrompt === true
? {}
: {
v4PromptBuilder: (sessionBinding) => {
const built = this.v4DeliveryPrompt(group, controller, {
kind, phase, snapshot, receiptRecords: deliveryReceiptRecords,
role, targetKinds: participantKinds, slot,
options: {
reviewTarget: phase === 'challenge'
? challengeBinding?.proposalKind || ''
: '',
assignedProposalText,
extraContext,
...(['synthesis', 'verification'].includes(phase)
? { artifactContext: { coordinationPlan } }
: {}),
},
sessionBinding,
skillHints: context.rootSkillsByKind.get(kind) || [],
})
preparedDelivery = built.delivery
return built
},
}),
skillHints: context.rootSkillsByKind.get(kind) || [],
knowledgeBaseHints: context.rootKnowledgeBasesByKind.get(kind) || [],
},
@@ -3692,7 +4162,7 @@ class LocalWorkspaceAutoRunner {
synthesisPendingMessage = result.pendingMessage || result.message || null
} else if (result.pendingMessage?.content) {
phasePendingMessages.push({ kind: item.kind, pendingMessage: result.pendingMessage })
if (phase === 'challenge') {
if (['discussion', 'challenge'].includes(phase)) {
this.commitV4PhaseMessage({
group, controller, threadRootId, phase, kind: item.kind,
pendingMessage: result.pendingMessage,
@@ -3777,6 +4247,9 @@ class LocalWorkspaceAutoRunner {
&& !this.completeV4ResumedAgentSlotContinuation(controller, resumedGate)) {
throw new Error('LOCAL_RUN_PERSIST_FAILED')
}
const remainingKinds = Array.isArray(phaseInput.remainingKinds)
? phaseInput.remainingKinds
: []
this.v4CheckpointPhase(group, controller, {
targetKinds,
phase,
@@ -3786,7 +4259,7 @@ class LocalWorkspaceAutoRunner {
slots: phaseSlots,
writerKind,
currentKinds: [],
pendingKinds: [],
pendingKinds: remainingKinds,
participantKinds,
receipts: receiptRecords,
challengeBindings,
@@ -4053,6 +4526,9 @@ class LocalWorkspaceAutoRunner {
? controller.orchestration
: null
const targetKinds = [...snapshotTargetKinds]
controller.discussionStyle = existing?.discussionStyle
|| controller.discussionStyle
|| 'sequential'
let activeKinds = Array.isArray(existing?.activeKinds) && existing.activeKinds.length
? existing.activeKinds.filter(kind => targetKinds.includes(kind))
: [...targetKinds]
@@ -4609,6 +5085,24 @@ class LocalWorkspaceAutoRunner {
writerKind, verificationKinds, acceptance, participantKinds: activeKinds,
})
}
if (this.naturalAgentResponses && !coordinationPlan && !synthesisBinding && !synthesisRecovery) {
return this.runV4NaturalDiscussion(group, controller, threadRootId, context, {
targetKinds,
activeKinds,
writerKind,
batchId,
snapshot,
snapshotRecord,
snapshotHash,
slots,
receiptRecords,
latestFor,
runPhase,
checkpointPhase,
removePhaseFailures,
addRoundLimitNotice,
})
}
while (!controller.signal.aborted) {
controller.currentRound = round
if (!proposalComplete) {
@@ -5289,6 +5783,7 @@ class LocalWorkspaceAutoRunner {
group.id, 'auto', targetKinds, threadRootId, reservation, maxRounds, unlimitedRounds,
)
controller.v4 = v4
controller.discussionStyle = reservation?.discussionStyle || 'sequential'
const promise = (async () => {
let runStatus = 'failed'
try {
@@ -230,8 +230,9 @@ function v4Prompt({
const selectedAgentCount = Array.isArray(snapshot?.targetKinds) ? snapshot.targetKinds.length : 0
const requiredVerifierCount = Math.max(0, selectedAgentCount - 1)
const phaseInstruction = naturalResponse
? {
? {
proposal: 'Work independently on the user task and offer your own analysis, recommendations, and next steps.',
discussion: 'Continue from the available peer responses. Add useful evidence, challenge weak assumptions, resolve disagreements where possible, and advance the shared result.',
challenge: 'Review the available peer context as a subject-matter peer. Explain what you agree with, what is missing, and the clearest correction or direction.',
work: 'Complete the responsibility assigned to you. Report useful work, evidence, blockers, or handoffs in your own words.',
synthesis: 'Combine the available work into the clearest useful answer for the user. Resolve contradictions where possible and state remaining uncertainty plainly.',
@@ -270,12 +271,12 @@ function v4Prompt({
if (naturalResponse) {
return [
'You are participating in a local multi-agent discussion.',
`Your role this turn is ${role}.`,
`The current collaboration phase is ${phase}.`,
`User task:\n${cleanText(snapshot?.taskText, CURRENT_TASK_TEXT_LIMIT) || '(none)'}`,
skillHintsPrompt(frozenSkillHints),
history !== '(none)' ? `Relevant prior context:\n${history}` : '',
phaseInstruction,
'Engage with the other Agents as peers: discuss differences, challenge weak assumptions, build agreement where justified, and collaborate on the user\'s requested outcome.',
'After concrete deliverables exist, review them for material gaps before accepting the result.',
'Answer the user naturally in Markdown. Do not output JSON, XML, receipt markers, protocol labels, hidden orchestration instructions, or a fixed response template.',
'Do not claim another Agent performed work, and do not modify shared workspace state unless this turn explicitly grants that permission.',
].filter(Boolean).join('\n\n')
@@ -45,6 +45,8 @@ const {
validateV4SnapshotBody,
} = require('./local-workspace-context.cjs')
const DISCUSSION_STYLES = new Set(['sequential', 'agent-led'])
function hardBudgetFailure(error) {
return error?.code === 'LOCAL_BUDGET_EXHAUSTED'
|| error?.message === 'LOCAL_BUDGET_EXHAUSTED'
@@ -337,15 +339,28 @@ class LocalWorkspaceMessageSubmission {
if (regenerateMessageId && (mode !== 'manual' || targetKinds.length !== 1)) {
throw new Error('LOCAL_MESSAGE_REGENERATION_INVALID')
}
const v4 = (mode === 'manual' || mode === 'auto')
&& group.conversationType !== 'direct'
&& targetKinds.length > 1
&& !regenerateMessageId
&& input.protocol !== 'legacy'
&& this.v4Requested(input)
const requestedDiscussionStyle = input.discussionStyle == null
? ''
: cleanInline(input.discussionStyle, 40)
if (requestedDiscussionStyle && !DISCUSSION_STYLES.has(requestedDiscussionStyle)) {
throw new Error('LOCAL_DISCUSSION_STYLE_INVALID')
}
if (requestedDiscussionStyle && (mode !== 'auto' || !v4)) {
throw new Error('LOCAL_DISCUSSION_STYLE_UNSUPPORTED')
}
return {
mode,
targetKinds,
v4: (mode === 'manual' || mode === 'auto')
&& group.conversationType !== 'direct'
&& targetKinds.length > 1
&& !regenerateMessageId
&& input.protocol !== 'legacy'
&& this.v4Requested(input),
v4,
discussionStyle: mode === 'auto' && v4
? (requestedDiscussionStyle || 'sequential')
: '',
unlimitedRounds,
maxRounds,
requestedThreadRootId,
@@ -1428,6 +1443,7 @@ class LocalWorkspaceMessageSubmission {
regenerateMessageId,
routingDecision,
taskGraph,
discussionStyle,
} = this.validateInput(group, input)
const regeneration = this.resolveRegeneration(group, regenerateMessageId, targetKinds)
const reservation = this.reserveRun(
@@ -1439,6 +1455,7 @@ class LocalWorkspaceMessageSubmission {
unlimitedRounds,
)
reservation.v4 = v4
reservation.discussionStyle = discussionStyle
try {
if (regeneration) reservation.responseVersionRootId = regeneration.responseVersionRootId
this.configureRunBudget(reservation, input.budget || {})
@@ -1797,6 +1814,18 @@ class LocalWorkspaceMessageSubmission {
group.id, 'auto', targetKinds, threadRootId, maxRounds, unlimitedRounds,
)
reservation.v4 = input.protocol !== 'legacy' && this.v4Requested(input)
const requestedDiscussionStyle = input.discussionStyle == null
? ''
: cleanInline(input.discussionStyle, 40)
if (requestedDiscussionStyle && !DISCUSSION_STYLES.has(requestedDiscussionStyle)) {
this.releasePreparation(group.id, reservation)
throw new Error('LOCAL_DISCUSSION_STYLE_INVALID')
}
if (requestedDiscussionStyle && !reservation.v4) {
this.releasePreparation(group.id, reservation)
throw new Error('LOCAL_DISCUSSION_STYLE_UNSUPPORTED')
}
reservation.discussionStyle = requestedDiscussionStyle || 'sequential'
try {
this.configureRunBudget(reservation, input.budget || {})
const contextPack = this.createContextPack({
@@ -19,7 +19,7 @@ const FAILED_AGENT_STATUSES = new Set(['failed', 'stopped', 'timeout'])
const TERMINAL_PERSISTENCE_STATES = new Set(['pending', 'retrying', 'failed'])
const V4_PHASES = new Set([
'prepare', 'dispatch', 'running', 'reconcile', 'proposal', 'challenge',
'prepare', 'dispatch', 'running', 'reconcile', 'proposal', 'discussion', 'challenge',
'coordination', 'work', 'synthesis', 'verification', 'commit', 'committed', 'completed',
'human-gate', 'stopped', 'failed',
])
+29 -6
View File
@@ -66,7 +66,29 @@ const TERMINAL_RUN_STATUSES = new Set([
'completed', 'partial', 'failed', 'stopped', 'timeout', 'round-limit', 'interrupted',
'budget-exhausted', 'circuit-breaker',
])
const V4_VISIBLE_MESSAGE_PHASES = ['proposal', 'challenge', 'work', 'verification']
const V4_VISIBLE_MESSAGE_PHASES = ['proposal', 'discussion', 'challenge', 'work', 'verification']
function naturalV4MessageBindingMatches(message, record, phase) {
if (!record.orchestration?.discussionStyle || !['proposal', 'discussion'].includes(phase)) {
return true
}
const round = Number(message?.trace?.round) || 0
const slot = record.orchestration.slots?.find(candidate => (
candidate.agentKind === message.agentKind
))
if (round < 1 || !slot?.slotId) return false
const operationPhase = phase === 'discussion' ? `discussion:${round}` : phase
const operationId = `operation-${createHash('sha256').update(JSON.stringify([
record.runId,
record.taskId,
message.agentKind,
slot.slotId,
operationPhase,
round,
])).digest('hex')}`
return message.trace?.context?.operationId === operationId
&& message.trace?.context?.snapshotHash === record.orchestration.snapshotHash
}
function isCommittedV4PhaseMessage(message, record) {
const agentRunId = String(message?.trace?.agentRunId || '')
@@ -74,15 +96,16 @@ function isCommittedV4PhaseMessage(message, record) {
|| message?.trace?.runId !== record.runId || !message.agentKind || !agentRunId) {
return false
}
return V4_VISIBLE_MESSAGE_PHASES.some(phase => (
message.id === `message-${hashValue({
return V4_VISIBLE_MESSAGE_PHASES.some((phase) => {
if (message.id !== `message-${hashValue({
agentKind: message.agentKind,
agentRunId,
phase,
runId: record.runId,
taskId: record.taskId,
})}`
))
})}`) return false
return naturalV4MessageBindingMatches(message, record, phase)
})
}
class LocalWorkspace extends EventEmitter {
@@ -476,7 +499,7 @@ class LocalWorkspace extends EventEmitter {
const commitState = orchestration?.commitState
const candidateCommit = orchestration?.candidateCommit
const resumablePhase = [
'proposal', 'challenge', 'coordination', 'work', 'synthesis', 'verification',
'proposal', 'discussion', 'challenge', 'coordination', 'work', 'synthesis', 'verification',
].includes(orchestration?.phase)
const resumableCommit = ['commit', 'committed', 'completed'].includes(orchestration?.phase)
&& ['committing', 'committed'].includes(commitState?.status)
@@ -10,6 +10,7 @@ const {
normalizeExternalRunRef,
outcomeForAcpStopReason,
requireTerminalAgentResult,
terminalAuthenticationDiagnostic,
} = require('../../src/agents/agent-runtime-contract.cjs')
const { ALLOWED_KINDS } = require('../../src/agents/cli/cli-discovery.cjs')
@@ -98,3 +99,21 @@ test('failed and cancelled outcomes do not become successful results', () => {
&& error.failure.category === 'cancellation',
)
})
test('short terminal HTTP credential diagnostics cannot become successful results', () => {
const diagnostic = 'HTTP 401: Invalid token (request id: request-1)'
assert.equal(terminalAuthenticationDiagnostic(diagnostic), diagnostic)
assert.equal(
terminalAuthenticationDiagnostic('HTTP 403: Forbidden'),
'HTTP 403: Forbidden',
)
assert.throws(
() => requireTerminalAgentResult({ outcome: 'completed', text: diagnostic }),
error => error.message === 'LOCAL_AGENT_AUTH_REQUIRED'
&& error.failure.category === 'authentication'
&& error.diagnostic === diagnostic,
)
assert.equal(terminalAuthenticationDiagnostic(
'When an API returns HTTP 401: Invalid token, refresh the configured credential.',
), '')
})
@@ -19,6 +19,7 @@ const {
parseMimoOutput,
parseOpenCodeOutput,
parseOpenCodeReviewOutput,
parsePiOutput,
parseWorkBuddyOutput,
prepareCommand,
readHermesFinalResponse,
@@ -1062,6 +1063,62 @@ test('MiMo JSON output returns final text and session id', () => {
})
})
test('Pi uses the current non-interactive JSON mode and resumes by session id', () => {
const readOnly = invocation('pi', '/tmp/pi', '/tmp/work', 'pi-session', {
provider: { id: 'zgci', model: 'glm' },
})
assert.deepEqual(readOnly.args, [
'--mode', 'json', '--print', '--no-approve', '--session', 'pi-session',
])
assert.equal(readOnly.promptArg, true)
assert.equal(readOnly.eventTransport, 'json')
const writable = invocation('pi', '/tmp/pi', '/tmp/work', '', {
sandbox: 'workspace-write',
})
assert.deepEqual(writable.args, ['--mode', 'json', '--print', '--approve'])
})
test('Pi JSON output returns final text and session id from current CLI events', () => {
const raw = [
JSON.stringify({
type: 'session',
id: '01a03794-ae3f-72ea-9efd-cd2cd16d88a1',
cwd: '/tmp/work',
}),
JSON.stringify({
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: 'Pi ' },
}),
JSON.stringify({
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: 'reply' },
}),
JSON.stringify({
type: 'message_end',
message: {
role: 'assistant',
content: [{ type: 'text', text: 'Pi reply' }],
stopReason: 'stop',
},
}),
JSON.stringify({
type: 'turn_end',
message: { role: 'assistant', stopReason: 'stop' },
}),
JSON.stringify({ type: 'agent_settled' }),
].join('\n')
assert.deepEqual(parsePiOutput(raw), {
text: 'Pi reply',
sessionRef: '01a03794-ae3f-72ea-9efd-cd2cd16d88a1',
})
assert.deepEqual(profileOutput('pi', raw), {
text: 'Pi reply',
sessionRef: '01a03794-ae3f-72ea-9efd-cd2cd16d88a1',
outcome: 'completed',
})
})
test('Claude and Qwen stream JSON output returns the final reply and session id', () => {
for (const kind of ['claude', 'qwen']) {
const raw = [
@@ -1602,6 +1659,13 @@ test('every supported built-in Agent produces an explicit completion outcome', (
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', contentIndex: 1, delta: 'Pi Agent reply' },
}),
JSON.stringify({
type: 'message_end',
message: {
role: 'assistant', content: [{ type: 'text', text: 'Pi Agent reply' }],
stopReason: 'stop', rawStopReason: 'stop',
},
}),
JSON.stringify({ type: 'turn_end', stopReason: 'stop' }),
].join('\n'),
claude: JSON.stringify({
@@ -15,6 +15,7 @@ const {
parseKimiOutput,
parseMimoOutput,
parseOpenCodeOutput,
parsePiOutput,
parseWorkBuddyOutput,
prepareCommand,
readHermesFinalResponse,
@@ -1546,6 +1547,18 @@ test('Hermes receives selected Skill snapshots through the prompt without native
assert.deepEqual(acpSpec.args, ['acp'])
})
test('Pi uses non-interactive JSON flags and keeps provider settings out of arguments', () => {
const spec = invocation('pi', '/tmp/pi', '/tmp', 'pi-session-123', {
provider: { id: 'zgci', model: 'glm' },
})
assert.deepEqual(spec.args, [
'--mode', 'json', '--print', '--no-approve', '--session', 'pi-session-123',
])
assert.equal(spec.promptArg, true)
assert.equal(spec.eventTransport, 'json')
assert.equal(typeof parsePiOutput, 'function')
})
test('Hermes forwards one image and rejects additional images instead of dropping them', () => {
const first = path.resolve('diagram-one.png')
const second = path.resolve('diagram-two.png')
@@ -51,9 +51,15 @@ input.on('line', (line) => {
} else if (message.method === 'session/prompt') {
promptCount += 1
if (resumed) {
const prompt = message.params?.prompt?.[0]?.text || ''
update(sessionId, {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'HTTP 401: Invalid token' },
content: {
type: 'text',
text: prompt === 'auth prompt'
? 'HTTP 401: Invalid token (request id: request-1)'
: 'resumed turn',
},
})
send({ jsonrpc: '2.0', id: message.id, result: { stopReason: 'end_turn' } })
return
@@ -120,12 +126,22 @@ input.on('line', (line) => {
assert.equal(typeof adapters.shutdownAcpSessionRuntime, 'function')
await adapters.shutdownAcpSessionRuntime()
await within(readWhenReady(stoppedFile))
await assert.rejects(() => adapters.runAgent(agent, 'stale prompt', directory, {
const resumed = await adapters.runAgent(agent, 'resumed prompt', directory, {
acpPersistenceKey: 'workspace:hermes:direct',
sessionRef: first.sessionRef,
sessionTransport: 'acp',
env: { MELDWORK_TEST_SPAWN_COUNT_FILE: spawnCountFile },
}), { message: 'LOCAL_AGENT_SESSION_INVALID' })
})
assert.equal(resumed.text, 'resumed turn')
assert.equal(resumed.sessionRef, first.sessionRef)
assert.equal(fs.readFileSync(spawnCountFile, 'utf8'), '2')
await assert.rejects(() => adapters.runAgent(agent, 'auth prompt', directory, {
acpPersistenceKey: 'workspace:hermes:direct',
sessionRef: first.sessionRef,
sessionTransport: 'acp',
env: { MELDWORK_TEST_SPAWN_COUNT_FILE: spawnCountFile },
}), error => error.message === 'LOCAL_AGENT_AUTH_REQUIRED'
&& error.failure?.category === 'authentication')
})
test('ACP closes a start-only tool with the partial terminal outcome', async (t) => {
@@ -1422,8 +1422,11 @@ test('V4 invocation boundary resumes delta delivery from durable acknowledgement
workspace.save()
const rotatedA = await deliver(workspace, runA, receiptsFor(runA, 3), 'wave-3-rotated')
assert.ok(rotatedA.result)
assert.equal(calls.find(call => call.tag === 'A' && call.attempt === 'wave-3-rotated').sessionRef, '')
assert.equal(workspace.state.sessions[sessionKeyA], 'A-codex-native-2')
assert.equal(
calls.find(call => call.tag === 'A' && call.attempt === 'wave-3-rotated').sessionRef,
'A-codex-native-1',
)
assert.equal(workspace.state.sessions[sessionKeyA], 'A-codex-native-1')
const durableAAtRestart = ledger.get(runA.controller.runId)
const durableBAtRestart = ledger.get(runB.controller.runId)
@@ -1471,7 +1474,7 @@ test('V4 invocation boundary resumes delta delivery from durable acknowledgement
watermark: entry.watermark,
})), [{ sourceAgentKind: 'kimi', watermark: 4 }])
const postRestartCall = calls.find(call => call.attempt === 'post-restart-delta')
assert.equal(postRestartCall.sessionRef, 'A-codex-native-2')
assert.equal(postRestartCall.sessionRef, 'A-codex-native-1')
assert.match(postRestartCall.prompt, /A WAVE_4_kimi_LATEST/)
assert.doesNotMatch(postRestartCall.prompt, /A WAVE_3_/)
assert.deepEqual(
@@ -1487,7 +1490,7 @@ test('V4 invocation boundary resumes delta delivery from durable acknowledgement
.map(entry => ({ watermark: entry.watermark, status: entry.status })),
[{ watermark: 4, status: 'acknowledged' }],
)
assert.equal(restarted.state.sessions[sessionKeyA], 'A-codex-native-2')
assert.equal(restarted.state.sessions[sessionKeyA], 'A-codex-native-1')
restarted.activeRuns.delete(recoveredRunA.group.id)
})
@@ -391,6 +391,7 @@ async function verifyAutomaticGateRecovery(t, gateIndex) {
attachmentRecipients: [],
totalSuccesses: 0,
terminalFailureOccurred: false,
executionSequence: gateIndex + 1,
})
assert.equal(collaboration.version, 1)
assert.deepEqual(
@@ -3,7 +3,10 @@ const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const { runAgent } = require('../../src/agents/cli/cli-adapters.cjs')
const {
runAgent,
shutdownAcpSessionRuntime,
} = require('../../src/agents/cli/cli-adapters.cjs')
const { LocalWorkspace } = require('../../src/workspace/local-workspace.cjs')
const { createLegacyOutboundPayload } = require('../../src/collaboration/outbound-payload.cjs')
const { RunLedger } = require('../../src/runs/run-ledger.cjs')
@@ -460,7 +463,10 @@ test('LocalWorkspace persists and resumes ACP permission approval and rejection
test('ACP permission Gate survives shutdown and resumes the persisted Session after approval', async (t) => {
const { directory, options } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
t.after(async () => {
await shutdownAcpSessionRuntime()
fs.rmSync(directory, { recursive: true, force: true })
})
const ledgerPath = path.join(directory, 'run-ledger.json')
const cli = acpPermissionExecutable(directory)
const ledger = new RunLedger({ storagePath: ledgerPath })
@@ -39,6 +39,61 @@ test('direct Hermes conversations reuse one persistent ACP runtime key across me
assert.equal(calls[1].runOptions.acpPersistenceKey, calls[0].runOptions.acpPersistenceKey)
})
test('direct Pi conversations reuse the native JSON session across messages', async (t) => {
const { directory, calls, options } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
options.detectAgents = async () => [{
kind: 'pi', name: 'Pi Agent', executable: '/tmp/pi', version: '0.84.2',
}]
options.runAgent = async (agent, prompt, workdir, runOptions) => {
calls.push({ agent, prompt, workdir, runOptions })
const sessionRef = runOptions.sessionRef || 'pi-json-session'
await runOptions.onSessionRef(sessionRef)
return { text: `pi reply ${calls.length}`, sessionRef, outcome: 'completed' }
}
const workspace = new LocalWorkspace(options)
await workspace.refreshAgents()
const group = workspace.createGroup({
name: 'Pi direct', agentKinds: ['pi'], workdir: directory,
conversationType: 'direct', directAgentKind: 'pi',
})
await workspace.sendMessage({ groupId: group.id, text: 'first' })
await workspace.sendMessage({ groupId: group.id, text: 'second' })
assert.deepEqual(calls.map(call => call.agent.kind), ['pi', 'pi'])
assert.deepEqual(calls.map(call => call.runOptions.sessionRef), ['', 'pi-json-session'])
assert.deepEqual(calls.map(call => call.runOptions.sessionTransport), ['', ''])
})
test('group conversations dispatch Pi alongside other available Agents', async (t) => {
const { directory, calls, options } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
options.detectAgents = async () => [
{ kind: 'pi', name: 'Pi Agent', executable: '/tmp/pi', version: '0.84.2' },
{ kind: 'codex', name: 'Codex', executable: '/tmp/codex', version: '0.144.0' },
]
options.runAgent = async (agent, prompt, workdir, runOptions) => {
calls.push({ agent, prompt, workdir, runOptions })
const sessionRef = runOptions.sessionRef || `${agent.kind}-session`
await runOptions.onSessionRef(sessionRef)
return { text: `${agent.kind} reply`, sessionRef, outcome: 'completed' }
}
const workspace = new LocalWorkspace(options)
await workspace.refreshAgents()
const group = workspace.createGroup({
name: 'Pi group', agentKinds: ['pi', 'codex'], workdir: directory,
})
await workspace.sendMessage({
groupId: group.id, text: 'ask the group', targetKinds: ['pi', 'codex'],
})
assert.deepEqual(calls.map(call => call.agent.kind).sort(), ['codex', 'pi'])
const messages = workspace.snapshot().messages.filter(item => item.role === 'agent')
assert.deepEqual(messages.map(item => item.agentKind).sort(), ['codex', 'pi'])
})
test('group OpenCode conversations reuse one persistent ACP runtime key across rounds', async (t) => {
const { directory, calls, options } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
@@ -70,6 +125,72 @@ test('group OpenCode conversations reuse one persistent ACP runtime key across r
assert.equal(opencodeCalls[1].runOptions.acpPersistenceKey, opencodeCalls[0].runOptions.acpPersistenceKey)
})
test('Natural V4 keeps Hermes, OpenCode, and Claude Code on one Session per group task',
async (t) => {
const { directory, calls, options } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
options.naturalAgentResponses = true
options.detectAgents = async () => [
{
kind: 'hermes', name: 'Hermes CLI', executable: '/tmp/hermes', version: '2',
acpAvailable: true,
},
{ kind: 'opencode', name: 'OpenCode CLI', executable: '/tmp/opencode', version: '1' },
{ kind: 'claude', name: 'Claude Code', executable: '/tmp/claude', version: '1' },
]
options.runAgent = async (agent, prompt, workdir, runOptions) => {
calls.push({ agent, prompt, workdir, runOptions })
const sessionRef = runOptions.sessionRef || `${agent.kind}-native-session`
if (['hermes', 'opencode'].includes(agent.kind)) {
await runOptions.onSessionRef(sessionRef, { transport: 'acp' })
}
return { text: `${agent.kind} round response`, sessionRef, outcome: 'completed' }
}
const workspace = new LocalWorkspace(options)
await workspace.refreshAgents()
const group = workspace.createGroup({
name: 'Native Session continuity',
agentKinds: ['hermes', 'opencode', 'claude'],
workdir: directory,
})
await workspace.sendMessage({
groupId: group.id,
text: 'Continue one shared task for three rounds.',
mode: 'auto',
discussionStyle: 'sequential',
targetKinds: group.agentKinds,
maxRounds: 3,
protocol: 'v4',
})
const controller = workspace.activeRuns.get(group.id)
assert.ok(controller)
await controller.promise
for (const kind of group.agentKinds) {
const agentCalls = calls.filter(call => call.agent.kind === kind)
assert.deepEqual(agentCalls.map(call => call.runOptions.sessionRef), [
'', `${kind}-native-session`, `${kind}-native-session`,
])
assert.equal(typeof agentCalls[0].runOptions.acpPersistenceKey, 'string')
assert.equal(agentCalls[0].runOptions.acpPersistenceKey.length > 0, true)
assert.equal(agentCalls.every(call => (
call.runOptions.acpPersistenceKey === agentCalls[0].runOptions.acpPersistenceKey
)), true)
}
assert.deepEqual(
calls.filter(call => call.agent.kind === 'hermes')
.map(call => call.runOptions.sessionTransport),
['', 'acp', 'acp'],
)
assert.deepEqual(
calls.filter(call => call.agent.kind === 'opencode')
.map(call => call.runOptions.sessionTransport),
['', 'acp', 'acp'],
)
})
test('Workspace stream bridge redacts split credentials and paths while preserving tools', async (t) => {
const { directory, options } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
@@ -376,55 +376,27 @@ async function createManualPermissionRestart(t, restartOptions = {}) {
}
}
test('Manual V4 native ACP permission restart requires whole-slot recovery and a fresh Gate', async (t) => {
test('Manual V4 native ACP permission restart resumes the persisted Session without a fresh Gate',
async (t) => {
const recovery = await createManualPermissionRestart(t)
const {
calls, originalGate, restarted, restartedLedger, beforeApprovalCalls, waiting, writerSlot,
calls, originalGate, restarted, restartedLedger, beforeApprovalCalls, writerSlot,
} = recovery
const recoveryGate = await pendingGate(restarted, gate => (
gate.gateId !== originalGate.gateId && gate.type === 'retry'
))
const recoveryRequest = restarted.humanGateStore.request(recoveryGate.gateId)
const recovered = restartedLedger.get(originalGate.runId)
const recoveredWriter = recovered.orchestration.slots.find(slot => slot.agentKind === 'hermes')
const terminal = await terminalRun(restartedLedger, originalGate.runId)
const recoveredWriter = terminal.orchestration.slots.find(slot => slot.agentKind === 'hermes')
const hermesCalls = calls.filter(call => call.kind === 'hermes')
assert.equal(calls.length, beforeApprovalCalls + 1)
assert.equal(calls.at(-1).sessionRef, 'hermes-v4-native-session')
assert.equal(recovered.status, 'waiting')
assert.equal(recoveredWriter.status, 'running')
assert.equal(terminal.status, 'completed', terminal.reason)
assert.equal(recoveredWriter.status, 'completed')
assert.equal(recoveredWriter.attempt, writerSlot.attempt)
assert.equal(recoveredWriter.operationId, writerSlot.operationId)
assert.equal(recoveredWriter.commitStatus, 'pending')
assert.deepEqual(recoveredWriter.resultRefs.workflowOutcomeRefs || [], [])
assert.deepEqual(recoveryRequest, {
phase: 'manual-writer-recovery',
batchId: waiting.orchestration.batchId,
slotId: writerSlot.slotId,
operationId: writerSlot.operationId,
attempt: writerSlot.attempt,
outcomeCertainty: 'unknown_outcome',
sideEffectsPossible: true,
})
for (const kind of ['codex', 'workbuddy']) {
assert.equal(calls.filter(call => call.kind === kind).length, 1)
}
restarted.decideHumanGate(recoveryGate.gateId, {
status: 'approved', optionId: 'retry-once', actorId: 'local-user',
})
const freshPermissionGate = await pendingGate(restarted, gate => (
gate.type === 'permission' && ![originalGate.gateId, recoveryGate.gateId].includes(gate.gateId)
))
assert.equal(calls.filter(call => call.kind === 'hermes').length, 3)
assert.equal(calls.at(-1).sessionRef, '')
assert.notEqual(freshPermissionGate.gateId, originalGate.gateId)
restarted.decideHumanGate(freshPermissionGate.gateId, {
status: 'approved', optionId: 'allow-once', actorId: 'local-user',
})
const terminal = await terminalRun(restartedLedger, originalGate.runId)
assert.equal(terminal.status, 'completed', terminal.reason)
assert.equal(calls.filter(call => call.kind === 'hermes').length, 3)
assert.equal(hermesCalls.length, 2)
assert.deepEqual(hermesCalls.map(call => call.operationId), [
writerSlot.operationId, writerSlot.operationId,
])
assert.equal(restarted.listHumanGates({ pendingOnly: true }).length, 0)
for (const kind of ['codex', 'workbuddy']) {
assert.equal(calls.filter(call => call.kind === kind).length, 1)
}
@@ -494,13 +466,28 @@ test('Manual V4 native permission rejects an already accepted bound operation be
assert.equal(calls.length, beforeApprovalCalls)
})
test('Manual V4 native permission restart recovers a missing or naturally rotated Session', async (t) => {
test('Manual V4 native permission restart recovers a missing Session and preserves a V4 Session',
async (t) => {
for (const sessionState of ['missing', 'rotated']) {
await t.test(sessionState, async (subtest) => {
const recovery = await createManualPermissionRestart(subtest, { sessionState })
const {
calls, originalGate, restarted, restartedLedger, beforeApprovalCalls, writerSlot,
} = recovery
if (sessionState === 'rotated') {
const terminal = await terminalRun(restartedLedger, originalGate.runId)
const recoveredWriter = terminal.orchestration.slots.find(slot => (
slot.agentKind === writerSlot.agentKind
))
assert.equal(calls.length, beforeApprovalCalls + 1)
assert.equal(calls.at(-1).sessionRef, 'hermes-v4-native-session')
assert.equal(terminal.status, 'completed', terminal.reason)
assert.equal(recoveredWriter.status, 'completed')
assert.equal(recoveredWriter.operationId, writerSlot.operationId)
assert.equal(recoveredWriter.attempt, writerSlot.attempt)
assert.equal(restarted.listHumanGates({ pendingOnly: true }).length, 0)
return
}
const recoveryGate = await pendingGate(restarted, gate => (
gate.type === 'retry' && gate.gateId !== originalGate.gateId
))
@@ -526,7 +513,7 @@ test('Manual V4 native permission restart recovers a missing or naturally rotate
}
})
test('Auto V4 read-only native ACP permission restart completes through a fresh Gate', async (t) => {
test('Auto V4 read-only native ACP permission restart resumes the persisted Session', async (t) => {
const { directory, options } = fixture()
const cli = hermesAcpExecutable(directory)
const ledger = new RunLedger({
@@ -551,7 +538,7 @@ test('Auto V4 read-only native ACP permission restart completes through a fresh
})
const send = workspace.sendMessage({
groupId: group.id,
text: 'Resume one native read-only proposal through a fresh permission Gate.',
text: 'Resume one native read-only proposal through its persisted Session.',
mode: 'auto',
maxRounds: 3,
targetKinds: ['codex', 'hermes', 'workbuddy'],
@@ -591,23 +578,19 @@ test('Auto V4 read-only native ACP permission restart completes through a fresh
status: 'approved', optionId: 'allow-once', actorId: 'local-user',
})
const freshGate = await pendingGate(restarted, gate => (
gate.type === 'permission' && gate.gateId !== originalGate.gateId
))
const terminal = await terminalRun(restartedLedger, originalGate.runId)
const resumedCalls = calls.filter(call => (
call.kind === 'hermes' && call.phase === 'proposal'
))
assert.equal(calls.length, beforeApprovalCalls + 2)
assert.equal(calls[beforeApprovalCalls].kind, 'hermes')
assert.equal(calls[beforeApprovalCalls].phase, 'proposal')
assert.equal(calls[beforeApprovalCalls].sessionRef, 'hermes-v4-native-session')
assert.deepEqual(resumedCalls.map(call => call.operationId), [
gatedSlot.operationId, gatedSlot.operationId, gatedSlot.operationId,
gatedSlot.operationId, gatedSlot.operationId,
])
assert.equal(resumedCalls.at(-1).sessionRef, '')
restarted.decideHumanGate(freshGate.gateId, {
status: 'approved', optionId: 'allow-once', actorId: 'local-user',
})
const terminal = await terminalRun(restartedLedger, originalGate.runId)
assert.equal(resumedCalls.at(-1).sessionRef, 'hermes-v4-native-session')
assert.equal(terminal.status, 'completed', terminal.reason)
assert.equal(restarted.listHumanGates({ pendingOnly: true }).length, 0)
for (const kind of ['codex', 'workbuddy']) {
assert.equal(calls.filter(call => call.kind === kind && call.phase === 'proposal').length, 1)
}
File diff suppressed because it is too large. Load diff
@@ -346,7 +346,10 @@ test('Natural V4 prompts carry task context without exposing the receipt templat
assert.match(prompt, /Compare the available skills/)
assert.match(prompt, /Answer the user naturally in Markdown/)
assert.doesNotMatch(prompt, /Receipt JSON shape|MELDWORK_COLLABORATION|MELDWORK_V4_FROZEN_SNAPSHOT_V1/)
assert.doesNotMatch(
prompt,
/current collaboration phase|your role|Receipt JSON shape|MELDWORK_COLLABORATION|MELDWORK_V4_FROZEN_SNAPSHOT_V1/i,
)
})
test('Natural V4 challenge responses receive a deterministic internal responsibility graph', () => {
+123
View File
@@ -0,0 +1,123 @@
# Meldwork AI Discoverability Index
This page is a factual index for search systems and AI assistants. Statements below are based on the repository source and documentation; they do not describe capabilities that are not implemented here.
## Canonical identity
- **Project:** Meldwork
- **Type:** Local-first Electron desktop application with a Vue renderer (`desktop/package.json`, `frontend/package.json`, `desktop/src/main.cjs`, `frontend/src/main.js`).
- **Primary subject:** A local workspace for direct conversations and user-selected multi-Agent conversations. The Electron main process discovers and invokes local Agent CLIs, while the renderer receives a constrained preload API.
- **Runtime boundary:** Conversations, orchestration state, run checkpoints, attachments, Skill snapshots, Provider metadata, and selected knowledge-source state are stored locally. The repository does not require an application server, account/tenant system, or Meldwork-hosted remote conversation store (`architecture.md`).
- **Repository terms:** local-first AI workspace; multi-Agent collaboration; Agent CLI orchestration; Electron desktop; Vue renderer; Harness; evidence-aware runs; human-in-the-loop decisions; Agent Connector SDK.
## Search intents this repository answers
Use these phrases when looking for this project or related implementation evidence:
- local-first multi-Agent desktop workspace / 本地优先多 Agent 桌面工作空间
- Electron app for multiple AI Agent CLIs / Electron 多 AI Agent CLI 客户端
- direct and group AI Agent conversations / AI Agent 直接会话与群聊
- concurrent multi-Agent responses with frozen context / 共享快照与并发 Agent 回复
- Auto Discussion V4 proposal, challenge, responsibility, synthesis, verification / 多 Agent 提案、质询、职责协商、整合与复核
- Agent Client Protocol (ACP) desktop integration / ACP(Agent Client Protocol)桌面集成
- JSONL and stream-json Agent runtime normalization / JSONL、stream-json Agent 事件归一化
- local Agent Connector SDK with content-addressed manifests / 内容寻址的本地 Agent Connector SDK
- durable sanitized run ledger, evidence-aware runs, and human-in-the-loop Human Gate recovery / 脱敏运行账本、Evidence-aware 运行与 human-in-the-loop 人类审批门恢复
- local Skills, image/file attachments, Provider profiles, and Obsidian knowledge selection / 本地 Skill、附件、Provider 与 Obsidian 知识源
- deterministic local Eval Harness for Agent workflows / 本地确定性 Agent Eval Harness
## Main execution path
1. Electron starts at `desktop/src/main.cjs` and loads the built renderer through a local `file:` document.
2. `desktop/src/preload.cjs` exposes the named `window.meldworkDesktop` IPC surface; the renderer does not receive arbitrary filesystem, shell, executable-path, credential, or native-session access.
3. `desktop/src/agents/cli/cli-discovery.cjs` scans fixed executable names and known platform paths. `desktop/src/agents/cli/cli-adapters.cjs` validates capability and invokes the selected executable using code-defined arguments.
4. `desktop/src/workspace/local-workspace.cjs` coordinates conversations, direct/group targets, session references, persistence, and run lifecycle. `local-workspace-message-submission.cjs` accepts a message and starts direct, manual, or automatic execution.
5. `desktop/src/agents/cli/cli-output-parsers.cjs` and `cli-runtime-event-mappers.cjs` reduce Agent output to final text, bounded status/progress, plans/reasoning summaries where available, tool lifecycle summaries, terminal outcomes, and a main-only session reference.
6. `desktop/src/runs/run-ledger.cjs`, `run-scheduler.cjs`, and `failure-policy.cjs` checkpoint bounded sanitized state, enforce budgets/limits, classify failures, and support stop/recovery semantics.
7. `frontend/src/App.vue`, `frontend/src/components/ConversationTimelineView.vue`, and `RunTracePanel.vue` render messages and sanitized run events. Final messages and compact evidence are persisted in the local workspace.
## Collaboration modes
- **Direct conversation:** One selected Agent with conversation-and-Agent session continuity when the adapter supports it.
- **Concurrent Responses (`manual`):** A selected group runs from one frozen task snapshot; selected members are retained and results are published in stable member order after the batch barrier (`architecture.md`, `docs/flows.md`).
- **Auto Discussion (`auto`, V4 `discussion`):** Selected Agents produce independent proposals, challenge and negotiate one normalized responsibility plan, execute dependency-aware work packages, use one synthesis writer when workspace writes are enabled, and independently verify the candidate. The Harness validates receipts, permissions, watermarks, Human Gates, and idempotent commits; it does not privately assign roles (`desktop/src/collaboration/orchestration-v4-records.cjs`, `desktop/src/workspace/local-workspace-auto-runner.cjs`).
- **Context:** Messages may carry up to four target-scoped Skill snapshots and up to four knowledge-source selections. Attachment validation and per-Agent image limits occur before execution (`docs/automation.md`, `docs/flows.md`).
## Built-in Agent adapters and wire protocols
The fixed catalog is defined in `desktop/src/agents/cli/cli-discovery.cjs`. Command aliases are discovery names, not claims that the executable is bundled.
| Agent kind | Display name / command aliases | Invocation or event protocol in code | Runtime notes |
| --- | --- | --- | --- |
| `codex` | Codex / `codex` | Codex App Server JSONL (`codex exec --json`) | Streaming answer deltas, plans, reasoning, tool start/result, sessions; read-only or workspace-write sandbox. |
| `hermes` | Hermes / `hermes` | ACP JSON-RPC (`hermes acp`), with legacy terminal-text fallback (`hermes chat --quiet`) | Session continuity; legacy path includes result recovery from a local message watermark when available. |
| `openclaw` | OpenClaw / `openclaw` | ACP JSON-RPC (`openclaw ... acp`), with legacy terminal-document JSON fallback | Managed local runtime and explicit tool policy; live behavior depends on the installed CLI and Provider. |
| `workbuddy` | WorkBuddy / `codebuddy` | `stream-json` invocation with bounded nested turn count | Read-only/plan or workspace-write/acceptEdits permission mode; session resume supported by the invocation. |
| `pi` | Pi Agent / `pi`, `pi-agent`, `piagent` | Pi JSONL (`--mode json --print`) | Streaming answer, reasoning, and tool lifecycle events; session resume supported. |
| `kimi` | Kimi / `kimi` | ACP `plan` mode for read-only; native `stream-json --prompt` for workspace-write | The two permission paths use different transports. |
| `mimo` | MiMo / `mimo` | ACP JSON-RPC (`mimo acp --pure`), JSON fallback (`mimo run --format json`) | Plan/build mode follows permission; session resume supported. |
| `claude` | Claude Code / `claude` | Anthropic-style `stream-json` | Streaming answer, reasoning, plans, tool lifecycle, and session resume. |
| `gemini` | Gemini CLI / `gemini` | Gemini `stream-json` | Streaming answer and tool start/result events; session resume supported. |
| `opencode` | OpenCode / `opencode` | ACP JSON-RPC (`opencode acp --pure`), JSON fallback (`opencode run --format json`) | Plan/build mode follows permission; session resume supported; image/file arguments are validated. |
| `qwen` | Qwen Code / `qwen` | Anthropic-style `stream-json` | Plan/auto-edit approval mode; optional configured Provider and session resume. |
| `opencodereview` | OpenCodeReview / `ocr` | Terminal JSON document (`ocr review --format json`) | Read-only code-review adapter; output is final text/evidence without session or tool lifecycle events. |
The event profile mapping is explicit in `desktop/src/agents/cli/cli-event-profiles.cjs`; ACP transport is implemented in `desktop/src/agents/cli/cli-acp-runner.cjs`. The runtime capability contract (`desktop/src/agents/agent-runtime-contract.cjs`) recognizes domains including `software-development`, `software-review`, `research`, `document-production`, `tool-use`, and `automation`, input types including text/image/audio/video/file/structured-data, and permission modes `read-only` and `workspace-write`.
Custom local Agents are also supported through `desktop/src/agents/custom-agent-contract.cjs` and `custom-agent-store.cjs`. A custom definition uses an absolute executable path, allowlisted arguments, and either `stdin` or `argument` prompt mode; custom execution currently requires `workspace-write` and is not part of the fixed built-in protocol table.
## Core modules and public technical documents
| Area | Source of truth |
| --- | --- |
| Electron bootstrap, IPC trust boundary, persistence wiring | `desktop/src/main.cjs`, `desktop/src/shell/main-ipc.cjs`, `desktop/src/preload.cjs` |
| Vue renderer and desktop lifecycle | `frontend/src/main.js`, `frontend/src/App.vue`, `frontend/src/desktop.js` |
| Agent discovery, invocation, parsing, runtime events | `desktop/src/agents/cli/cli-discovery.cjs`, `cli-invocations.cjs`, `cli-adapters.cjs`, `cli-output-parsers.cjs`, `cli-runtime-event-mappers.cjs` |
| Conversations, Context Packs, sessions, message submission | `desktop/src/workspace/local-workspace.cjs`, `local-workspace-context-packs.cjs`, `local-workspace-message-submission.cjs` |
| V4 orchestration and recovery | `desktop/src/collaboration/orchestration-v4-records.cjs`, `desktop/src/workspace/local-workspace-auto-runner.cjs`, `desktop/src/runs/run-ledger.cjs` |
| Agent Connector packages | `desktop/src/agents/connectors/`, `docs/agent-connector-sdk.md` |
| Local Skill discovery and trust | `desktop/src/skills/`, `docs/local-skill-contracts.md` |
| Knowledge sources | `desktop/src/knowledge/`, `docs/flows.md` |
| Optional Cloud/Channel runtimes | `desktop/src/agents/cloud/`, `desktop/src/channels/`, `docs/integrations/cloud-agent-bridge.md`, `docs/automation.md` |
| Tests and CI | `desktop/test/`, `frontend/src/__tests__/meldwork/`, `.github/workflows/ci.yml`, `docs/tests.md` |
## Installation and verification commands
Prerequisite: Node.js `>=22.12.0` and npm (`desktop/package.json`, `frontend/package.json`). From the repository root:
```bash
npm --prefix frontend ci
npm --prefix desktop ci
npm --prefix desktop run dev
```
Focused and full checks:
```bash
npm --prefix frontend test
npm --prefix frontend run build
npm --prefix frontend run build:desktop
npm --prefix desktop test:agents
npm --prefix desktop test:runs
npm --prefix desktop test:workspace
npm --prefix desktop test:security
npm --prefix desktop test
npm --prefix desktop run eval:deterministic
npm --prefix desktop run connector:conformance
npm --prefix desktop run pack
```
`desktop/test/README.md` documents suite discovery. CI runs frontend tests/builds on Ubuntu and desktop tests, deterministic Eval Harness, and Electron packaging on macOS (`.github/workflows/ci.yml`).
## Boundaries and explicit non-goals
- The current packaged release target is Apple silicon macOS. The V1.0.3 prerelease is ad-hoc signed, not Apple Developer ID signed or notarized (`docs/releases/Meldwork-V1.0.3.md`, `docs/tests.md`).
- Automatic participant selection from a larger roster, cross-user remote collaboration, enterprise RBAC/governance, a production Cloud/Channel Agent service, and an Outcome Network are outside the current release boundary.
- Cloud and Channel Connector runtimes exist as optional main-process contracts, but no concrete Cloud or Channel Connector is enabled by default. The documented SSH bridge currently supports Codex read-only only; remote writes, attachments, and session resume are not supported (`docs/integrations/cloud-agent-bridge.md`).
- Agent output and model access depend on the locally installed CLI, authentication, Provider, declared capabilities, and upstream protocol behavior. Repository fixtures and unit tests do not certify every Agent installation, version, operating system, or live Provider.
- Workspace writes default to disabled. Agent processes run with the local operating-system user's permissions; permission flags and read-only instructions are not an OS sandbox (`architecture.md`, `docs/permissions.md`).
- The repository is licensed under the **Apache License 2.0**. Third-party Agent products, models, dependencies, and brand assets remain subject to their own terms (`LICENSE`, `NOTICE`, `COMMERCIAL_USE.md`).
## Aliases and keyword index
`Meldwork`, `meldwork`, `local-first AI workspace`, `multi-Agent collaboration`, `multi-agent orchestration`, `AI Agent work cell`, `Electron Agent desktop`, `Vue Agent workspace`, `Agent CLI manager`, `evidence-aware`, `human-in-the-loop`, `Codex`, `Hermes`, `OpenClaw`, `WorkBuddy`, `Pi Agent`, `Kimi Code`, `MiMo Code`, `Claude Code`, `Gemini CLI`, `OpenCode`, `Qwen Code`, `OpenCodeReview`, `ACP`, `Agent Client Protocol`, `JSONL`, `stream-json`, `Agent Connector SDK`, `Context Pack`, `Run Ledger`, `Human Gate`, `Auto Discussion V4`, `Concurrent Responses`, `frozen task snapshot`, `responsibility graph`, `typed Artifact`, `Evidence`, `Skill snapshot`, `Provider profile`, `Obsidian knowledge source`, `local Electron multi-agent app`, `本地优先`, `多 Agent 协作`, `Agent CLI 编排`, `桌面端 Agent 工作空间`, `多 Agent 群聊`, `并发回复`, `自动讨论`, `职责协商`, `运行账本`, `人类审批门`, `Agent 连接器 SDK`.
+3 -3
View File
@@ -1,13 +1,13 @@
{
"name": "meldwork-frontend",
"version": "0.1.3",
"version": "0.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meldwork-frontend",
"version": "0.1.3",
"license": "SEE LICENSE IN LICENSE",
"version": "0.1.4",
"license": "Apache-2.0",
"dependencies": {
"@vicons/ionicons5": "^0.12.0",
"dompurify": "^3.4.13",
+2 -2
View File
@@ -1,10 +1,10 @@
{
"name": "meldwork-frontend",
"version": "0.1.3",
"version": "0.1.4",
"private": true,
"description": "Meldwork local-first multi-Agent collaboration workspace",
"author": "Ryder-MHumble",
"license": "SEE LICENSE IN LICENSE",
"license": "Apache-2.0",
"engines": {
"node": ">=22.12.0"
},
@@ -196,7 +196,7 @@ describe('Meldwork workbench', () => {
wrapper.unmount()
})
it('shows the Auto discussion Beta mode with an explanation placeholder and a coming-soon guard', async () => {
it('sends the Auto discussion Beta mode as an Agent-led V4 discussion', async () => {
const { wrapper, bridge } = await mountApp(({ state }) => {
state.groups.push({
id: 'group-auto-beta',
@@ -228,8 +228,18 @@ describe('Meldwork workbench', () => {
await wrapper.get('.send-button').trigger('click')
await flushPromises()
expect(bridge.localWorkspace.send).not.toHaveBeenCalled()
expect(wrapper.get('.toast-message').text()).toContain('coming soon')
expect(bridge.localWorkspace.send).toHaveBeenCalledWith({
groupId: 'group-auto-beta',
text: 'Plan a launch',
targetKinds: ['codex', 'hermes'],
skillHints: [],
knowledgeBaseHints: [],
attachments: [],
mode: 'auto',
discussionStyle: 'agent-led',
protocol: 'v4',
maxRounds: 6,
})
wrapper.unmount()
})
+1 -1
View File
@@ -1670,7 +1670,7 @@ describe('Meldwork workbench', () => {
await range.setValue(6)
await wrapper.get('.round-unlimited-button').trigger('click')
await flushPromises()
expect(wrapper.get('.confirmation-modal-body').text()).toContain('until consensus or manual stop')
expect(wrapper.get('.confirmation-modal-body').text()).toContain('until collaboration ends or you stop it')
expect(wrapper.find('.round-range-input').exists()).toBe(true)
await wrapper.get('.confirmation-modal-footer .primary-button').trigger('click')
await flushPromises()
@@ -15,7 +15,7 @@ afterEach(() => {
document.body.innerHTML = ''
})
describe('Unlimited-round review mode', () => {
describe('Unlimited-round discussion mode', () => {
it('keeps the normal composer surface while retaining the infinity cue', () => {
const styles = readStylesSource()
@@ -64,7 +64,7 @@ describe('Unlimited-round review mode', () => {
wrapper.unmount()
})
it('explains strict peer review in the confirmation and composer', async () => {
it('explains natural collaboration in the confirmation and composer', async () => {
const { wrapper, state, emitWorkspaceChanged } = await mountApp(({ state }) => {
state.groups.push({
id: 'group-1',
@@ -84,8 +84,9 @@ describe('Unlimited-round review mode', () => {
await wrapper.get('.round-unlimited-button').trigger('click')
await flushPromises()
expect(wrapper.get('.confirmation-modal-body').text()).toContain('strict peer review')
expect(wrapper.get('.confirmation-modal-body').text()).toContain('independently verify claims')
expect(wrapper.get('.confirmation-modal-body').text()).toContain('discuss, divide work, collaborate')
expect(wrapper.get('.confirmation-modal-body').text()).toContain('once concrete results exist')
expect(wrapper.get('.confirmation-modal-body').text()).not.toContain('strict peer review')
await wrapper.get('.confirmation-modal-footer .primary-button').trigger('click')
await flushPromises()
@@ -95,9 +96,9 @@ describe('Unlimited-round review mode', () => {
expect(wrapper.get('.round-settings-trigger').classes()).toContain('unlimited')
expect(wrapper.get('.round-unlimited-symbol').text()).toBe('∞')
expect(wrapper.get('.round-settings-trigger').text()).toContain('No round limit')
expect(wrapper.get('.round-unlimited-active').text()).toContain('report material defects')
expect(wrapper.get('.round-unlimited-active').text()).toContain('review material gaps')
expect(wrapper.get('.composer-box textarea').attributes('placeholder'))
.toBe('Message Review with strict peer review enabled')
.toBe('Message Review')
state.runningGroupIds = ['group-1']
state.runs = [{
@@ -62,20 +62,17 @@ export function useConversationExecution({
const text = draft.value.trim()
const attachments = composerAttachments.value.map(safeAttachmentPayload)
const mode = composerMode.value
const backendMode = mode === 'auto-beta' ? 'auto' : mode
if (!text && !attachments.length) {
notify(t('composer.messageRequired'))
return
}
if (mode === 'auto-beta') {
notify(`${t('composer.autoBetaLockTitle')} — ${t('composer.autoBetaLockBody')}`)
return
}
const targets = [...composerTargetKinds.value]
if (!targets.length) {
notify(t('composer.selectTarget'))
return
}
if (mode === 'auto' && targets.length < 2) {
if (backendMode === 'auto' && targets.length < 2) {
notify(t('error.autoAgentCount'))
return
}
@@ -91,7 +88,7 @@ export function useConversationExecution({
const previousComposerContext = captureComposerContext()
const previousAttachments = composerAttachments.value.map(attachment => ({ ...attachment }))
const v4GroupRun = group.conversationType !== 'direct'
&& (mode === 'manual' || mode === 'auto')
&& (backendMode === 'manual' || backendMode === 'auto')
&& (targets.length > 1 || automaticTeamFormation.value)
clearComposerContext()
composerAttachments.value = []
@@ -107,10 +104,11 @@ export function useConversationExecution({
skillHints,
knowledgeBaseHints,
attachments,
mode,
mode: backendMode,
...(mode === 'auto-beta' ? { discussionStyle: 'agent-led' } : {}),
...(v4GroupRun ? { protocol: 'v4' } : {}),
maxRounds: maxRounds.value,
...(mode === 'auto' && unlimitedRounds.value ? { unlimitedRounds: true } : {}),
...(backendMode === 'auto' && unlimitedRounds.value ? { unlimitedRounds: true } : {}),
})
snapshot.value = normalizeSnapshot(await workspace.value.get())
} catch (error) {
+1 -1
View File
@@ -40,7 +40,7 @@ const EVENT_STATUSES = new Set([
])
const ORCHESTRATION_PHASES = new Set([
'prepare', 'dispatch', 'running', 'reconcile',
'proposal', 'challenge', 'coordination', 'work', 'synthesis', 'verification',
'proposal', 'discussion', 'challenge', 'coordination', 'work', 'synthesis', 'verification',
'commit', 'committed', 'failed', 'stopped', 'human-gate',
])
const TRACE_PHASES = new Set([
+4 -5
View File
@@ -375,6 +375,7 @@ export default {
'trace.eventConnectorLimited': 'Connector provided only the final answer; structured tool activity was unavailable.',
'trace.eventOther': 'Event',
'run.phase.proposal': 'Parallel proposals',
'run.phase.discussion': 'Discussion',
'run.phase.prepare': 'Preparing orchestration',
'run.phase.dispatch': 'Dispatching Agents',
'run.phase.running': 'Running',
@@ -402,7 +403,7 @@ export default {
'run.phaseSlots': '{completed}/{total} Agents',
'run.phaseFeedback': 'Current phase',
'composer.placeholder': 'Message {name}',
'composer.unlimitedPlaceholder': 'Message {name} with strict peer review enabled',
'composer.unlimitedPlaceholder': 'Message {name}',
'composer.targets': 'Replying Agents',
'composer.send': 'Send',
'composer.stop': 'Stop',
@@ -415,8 +416,6 @@ export default {
'composer.autoBeta': 'Auto discussion',
'composer.autoBetaHint': 'Beta: Agents @ each other into the discussion and the model orchestrates the rounds autonomously.',
'composer.autoBetaBadge': 'Beta',
'composer.autoBetaLockTitle': 'Auto discussion is a Beta feature',
'composer.autoBetaLockBody': 'True auto discussion — Agents autonomously @ each other to collaborate — is coming soon.',
'composer.smartTeam': 'Smart team',
'composer.smartTeamOn': 'On',
'composer.smartTeamOff': 'Off',
@@ -429,9 +428,9 @@ export default {
'composer.autoRounds': '{count} rounds',
'composer.maxRounds': 'Maximum rounds',
'composer.unlimitedRounds': 'No round limit',
'composer.unlimitedHint': 'Runs until consensus or manual stop. Agents must independently verify claims, challenge unsupported suggestions, and report material defects during acceptance. Agent timeouts and a mandatory circuit breaker still apply.',
'composer.unlimitedHint': 'The discussion continues until collaboration ends or you stop it. Agents can discuss, divide work, collaborate, and review material gaps once concrete results exist. Agent timeouts and the mandatory circuit breaker still apply.',
'composer.unlimitedConfirmTitle': 'Continue without a round limit?',
'composer.unlimitedConfirm': 'The discussion can run until consensus or manual stop and consume more Provider quota. Unlimited mode also applies strict peer review: Agents must independently verify claims, challenge unsupported suggestions, and report material defects during acceptance. Agent timeouts and the mandatory circuit breaker still apply. Continue?',
'composer.unlimitedConfirm': 'The discussion can continue until collaboration ends or you stop it and may consume more Provider quota. Agents can discuss, divide work, collaborate, and review material gaps once concrete results exist. Agent timeouts and the mandatory circuit breaker still apply. Continue?',
'composer.useBoundedRounds': 'Use bounded rounds',
'composer.addTarget': 'Include {agent}',
'composer.removeTarget': 'Exclude {agent}',
+4 -5
View File
@@ -375,6 +375,7 @@ export default {
'trace.eventConnectorLimited': '连接器只提供最终答案,未暴露结构化工具过程。',
'trace.eventOther': '事件',
'run.phase.proposal': '并行提案',
'run.phase.discussion': '讨论',
'run.phase.prepare': '准备编排',
'run.phase.dispatch': '分发 Agent',
'run.phase.running': '运行中',
@@ -402,7 +403,7 @@ export default {
'run.phaseSlots': '{completed}/{total} 个 Agent',
'run.phaseFeedback': '当前阶段',
'composer.placeholder': '发消息给 {name}',
'composer.unlimitedPlaceholder': '发消息给 {name}(已启用严格互审)',
'composer.unlimitedPlaceholder': '发消息给 {name}',
'composer.targets': '回复 Agent',
'composer.send': '发送',
'composer.stop': '停止',
@@ -415,8 +416,6 @@ export default {
'composer.autoBeta': '自动讨论',
'composer.autoBetaHint': 'Beta:Agent 可相互 @ 拉入讨论,由模型自主编排各轮协作。',
'composer.autoBetaBadge': 'Beta',
'composer.autoBetaLockTitle': '自动讨论为 Beta 功能',
'composer.autoBetaLockBody': 'Agent 自主 @ 协作的真正自动讨论即将上线,敬请期待。',
'composer.smartTeam': '智能组队',
'composer.smartTeamOn': '开启',
'composer.smartTeamOff': '关闭',
@@ -429,9 +428,9 @@ export default {
'composer.autoRounds': '{count} 轮',
'composer.maxRounds': '最大轮数',
'composer.unlimitedRounds': '不限轮数',
'composer.unlimitedHint': '群聊会持续到达成共识或手动停止。各 Agent 必须独立核验信息、质疑缺乏依据的建议,并在验收阶段当场指出重大问题;Agent 超时与不可关闭的熔断器仍然生效。',
'composer.unlimitedHint': '讨论会持续到协作自然结束或你手动停止。Agent 可以讨论、分工和协作,在形成具体结果后再检查重要缺口;Agent 超时与不可关闭的熔断器仍然生效。',
'composer.unlimitedConfirmTitle': '继续使用不限制轮数吗?',
'composer.unlimitedConfirm': '讨论会持续到达成共识或手动停止,并可能消耗更多 Provider 额度。不限轮数还会启用严格互审:各 Agent 必须独立核验信息、质疑缺乏依据的建议,并在验收阶段当场指出重大问题;Agent 超时与不可关闭的熔断器仍然生效。是否继续?',
'composer.unlimitedConfirm': '讨论会持续到协作自然结束或你手动停止,并可能消耗更多 Provider 额度。Agent 可以讨论、分工和协作,在形成具体结果后再检查重要缺口;Agent 超时与不可关闭的熔断器仍然生效。是否继续?',
'composer.useBoundedRounds': '改回固定轮数',
'composer.addTarget': '加入 {agent}',
'composer.removeTarget': '移除 {agent}',