Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb55fad008 | ||
|
|
8995f5f627 | ||
|
|
f562c68075 | ||
|
|
6aef211188 | ||
|
|
28127b3706 | ||
|
|
d47bff5433 | ||
|
|
1c9aabbf8e | ||
|
|
eb2234b598 | ||
|
|
e7c0223bfa | ||
|
|
3ce8ee4f49 | ||
|
|
07396fbf29 | ||
|
|
9198bf6548 | ||
|
|
956ddd0dd9 | ||
|
|
5465609d54 | ||
|
|
31f2d43826 | ||
|
|
2d0d7a9b55 | ||
|
|
6f52081e51 | ||
|
|
2c0857a85b | ||
|
|
411d0f92d1 | ||
|
|
58054c5e3b | ||
|
|
e2b8b08eef | ||
|
|
2acabc56cd | ||
|
|
38c480575d | ||
|
|
717d21373d | ||
|
|
95d5c93165 | ||
|
|
6e8f59ce16 | ||
|
|
eb0e1bb2ef | ||
|
|
ee0bd66708 | ||
|
|
17c38f6ede | ||
|
|
af7e4c5d30 | ||
|
|
9229046e3b | ||
|
|
f3794a9732 | ||
|
|
f08fe96529 | ||
|
|
f61d3beecd | ||
|
|
9867ad9b4e | ||
|
|
3f0ba50130 | ||
|
|
ff226e8bdd | ||
|
|
2750a344cb | ||
|
|
489f3d615e | ||
|
|
6021ca6a64 | ||
|
|
d288ee3a1b | ||
|
|
2df032a596 | ||
|
|
d0115c2908 | ||
|
|
67cc32d714 | ||
|
|
006d433c11 | ||
|
|
ffdcc12747 | ||
|
|
6fe4314360 | ||
|
|
011da08524 | ||
|
|
21a647161b | ||
|
|
381b8fefef | ||
|
|
709a9e9391 | ||
|
|
897b4ee054 | ||
|
|
fa56c3575d | ||
|
|
fad1cba603 | ||
|
|
050a457a40 | ||
|
|
7ab51fd621 | ||
|
|
91a0aad3f2 | ||
|
|
a68c53bd86 | ||
|
|
3a539d8e7c | ||
|
|
2faad0c322 | ||
|
|
33842bc959 | ||
|
|
61a71f3439 | ||
|
|
5fef68fc4b | ||
|
|
21a85b682c | ||
|
|
a54e84a208 | ||
|
|
e68699e61f | ||
|
|
540a96fa5e | ||
|
|
5efe559c03 | ||
|
|
092f38bd37 | ||
|
|
782f839d4f | ||
|
|
7752489936 |
No files matched your search
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 1024 1024">
|
||||
<path d="M854.6 288.7c6 6 9.4 14.1 9.4 22.6V928c0 17.7-14.3 32-32 32H192c-17.7 0-32-14.3-32-32V96c0-17.7 14.3-32 32-32h424.7c8.5 0 16.7 3.4 22.7 9.4l215.2 215.3zM790.2 326L602 137.8V326h188.2zM575.34 477.84l-61.22 102.3L452.3 477.8a12 12 0 0 0-10.27-5.79h-38.44a12 12 0 0 0-6.4 1.85 12 12 0 0 0-3.75 16.56l82.34 130.42-83.45 132.78a12 12 0 0 0-1.84 6.39 12 12 0 0 0 12 12h34.46a12 12 0 0 0 10.21-5.7l62.7-101.47 62.3 101.45a12 12 0 0 0 10.23 5.72h37.48a12 12 0 0 0 6.48-1.9 12 12 0 0 0 3.62-16.58l-83.83-130.55 85.3-132.47a12 12 0 0 0 1.9-6.5 12 12 0 0 0-12-12h-35.7a12 12 0 0 0-10.29 5.84z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 719 B |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 1024 1024">
|
||||
<path d="M854.6 288.7L639.4 73.4c-6-6-14.2-9.4-22.7-9.4H192c-17.7 0-32 14.3-32 32v832c0 17.7 14.3 32 32 32h640c17.7 0 32-14.3 32-32V311.3c0-8.5-3.4-16.6-9.4-22.6zM400 402c22.1 0 40 17.9 40 40s-17.9 40-40 40-40-17.9-40-40 17.9-40 40-40zm296 294H328c-6.7 0-10.4-7.7-6.3-12.9l99.8-127.2a8 8 0 0 1 12.6 0l41.1 52.4 77.8-99.2a8 8 0 0 1 12.6 0l136.5 174c4.3 5.2.5 12.9-6.1 12.9zm-94-370V137.8L790.2 326H602z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 530 B |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 1024 1024">
|
||||
<path d="M854.6 288.7c6 6 9.4 14.1 9.4 22.6V928c0 17.7-14.3 32-32 32H192c-17.7 0-32-14.3-32-32V96c0-17.7 14.3-32 32-32h424.7c8.5 0 16.7 3.4 22.7 9.4l215.2 215.3zM790.2 326L602 137.8V326h188.2zM426.13 600.93l59.11 132.97a16 16 0 0 0 14.62 9.5h24.06a16 16 0 0 0 14.63-9.51l59.1-133.35V758a16 16 0 0 0 16.01 16H641a16 16 0 0 0 16-16V486a16 16 0 0 0-16-16h-34.75a16 16 0 0 0-14.67 9.62L512.1 662.2l-79.48-182.59a16 16 0 0 0-14.67-9.61H383a16 16 0 0 0-16 16v272a16 16 0 0 0 16 16h27.13a16 16 0 0 0 16-16V600.93z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 635 B |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 1024 1024">
|
||||
<path d="M854.6 288.7c6 6 9.4 14.1 9.4 22.6V928c0 17.7-14.3 32-32 32H192c-17.7 0-32-14.3-32-32V96c0-17.7 14.3-32 32-32h424.7c8.5 0 16.7 3.4 22.7 9.4l215.2 215.3zM790.2 326L602 137.8V326h188.2zM633.22 637.26c-15.18-.5-31.32.67-49.65 2.96-24.3-14.99-40.66-35.58-52.28-65.83l1.07-4.38 1.24-5.18c4.3-18.13 6.61-31.36 7.3-44.7.52-10.07-.04-19.36-1.83-27.97-3.3-18.59-16.45-29.46-33.02-30.13-15.45-.63-29.65 8-33.28 21.37-5.91 21.62-2.45 50.07 10.08 98.59-15.96 38.05-37.05 82.66-51.2 107.54-18.89 9.74-33.6 18.6-45.96 28.42-16.3 12.97-26.48 26.3-29.28 40.3-1.36 6.49.69 14.97 5.36 21.92 5.3 7.88 13.28 13 22.85 13.74 24.15 1.87 53.83-23.03 86.6-79.26 3.29-1.1 6.77-2.26 11.02-3.7l11.9-4.02c7.53-2.54 12.99-4.36 18.39-6.11 23.4-7.62 41.1-12.43 57.2-15.17 27.98 14.98 60.32 24.8 82.1 24.8 17.98 0 30.13-9.32 34.52-23.99 3.85-12.88.8-27.82-7.48-36.08-8.56-8.41-24.3-12.43-45.65-13.12zM385.23 765.68v-.36l.13-.34a54.86 54.86 0 0 1 5.6-10.76c4.28-6.58 10.17-13.5 17.47-20.87 3.92-3.95 8-7.8 12.79-12.12 1.07-.96 7.91-7.05 9.19-8.25l11.17-10.4-8.12 12.93c-12.32 19.64-23.46 33.78-33 43-3.51 3.4-6.6 5.9-9.1 7.51a16.43 16.43 0 0 1-2.61 1.42c-.41.17-.77.27-1.13.3a2.2 2.2 0 0 1-1.12-.15 2.07 2.07 0 0 1-1.27-1.91zM511.17 547.4l-2.26 4-1.4-4.38c-3.1-9.83-5.38-24.64-6.01-38-.72-15.2.49-24.32 5.29-24.32 6.74 0 9.83 10.8 10.07 27.05.22 14.28-2.03 29.14-5.7 35.65zm-5.81 58.46l1.53-4.05 2.09 3.8c11.69 21.24 26.86 38.96 43.54 51.31l3.6 2.66-4.39.9c-16.33 3.38-31.54 8.46-52.34 16.85 2.17-.88-21.62 8.86-27.64 11.17l-5.25 2.01 2.8-4.88c12.35-21.5 23.76-47.32 36.05-79.77zm157.62 76.26c-7.86 3.1-24.78.33-54.57-12.39l-7.56-3.22 8.2-.6c23.3-1.73 39.8-.45 49.42 3.07 4.1 1.5 6.83 3.39 8.04 5.55a4.64 4.64 0 0 1-1.36 6.31 6.7 6.7 0 0 1-2.17 1.28z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 1024 1024">
|
||||
<path d="M854.6 288.7c6 6 9.4 14.1 9.4 22.6V928c0 17.7-14.3 32-32 32H192c-17.7 0-32-14.3-32-32V96c0-17.7 14.3-32 32-32h424.7c8.5 0 16.7 3.4 22.7 9.4l215.2 215.3zM790.2 326L602 137.8V326h188.2zM468.53 760v-91.54h59.27c60.57 0 100.2-39.65 100.2-98.12 0-58.22-39.58-98.34-99.98-98.34H424a12 12 0 0 0-12 12v276a12 12 0 0 0 12 12h32.53a12 12 0 0 0 12-12zm0-139.33h34.9c47.82 0 67.19-12.93 67.19-50.33 0-32.05-18.12-50.12-49.87-50.12h-52.22v100.45z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 571 B |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 1024 1024">
|
||||
<path d="M854.6 288.7c6 6 9.4 14.1 9.4 22.6V928c0 17.7-14.3 32-32 32H192c-17.7 0-32-14.3-32-32V96c0-17.7 14.3-32 32-32h424.7c8.5 0 16.7 3.4 22.7 9.4l215.2 215.3zM790.2 326L602 137.8V326h188.2zM320 482a8 8 0 0 0-8 8v48a8 8 0 0 0 8 8h384a8 8 0 0 0 8-8v-48a8 8 0 0 0-8-8H320zm0 136a8 8 0 0 0-8 8v48a8 8 0 0 0 8 8h184a8 8 0 0 0 8-8v-48a8 8 0 0 0-8-8H320z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 479 B |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 1024 1024">
|
||||
<path d="M854.6 288.7c6 6 9.4 14.1 9.4 22.6V928c0 17.7-14.3 32-32 32H192c-17.7 0-32-14.3-32-32V96c0-17.7 14.3-32 32-32h424.7c8.5 0 16.7 3.4 22.7 9.4l215.2 215.3zM790.2 326L602 137.8V326h188.2zM512 566.1l52.81 197a12 12 0 0 0 11.6 8.9h31.77a12 12 0 0 0 11.6-8.88l74.37-276a12 12 0 0 0 .4-3.12 12 12 0 0 0-12-12h-35.57a12 12 0 0 0-11.7 9.31l-45.78 199.1-49.76-199.32A12 12 0 0 0 528.1 472h-32.2a12 12 0 0 0-11.64 9.1L434.6 680.01 388.5 481.3a12 12 0 0 0-11.68-9.29h-35.39a12 12 0 0 0-3.11.41 12 12 0 0 0-8.47 14.7l74.17 276A12 12 0 0 0 415.6 772h31.99a12 12 0 0 0 11.59-8.9l52.81-197z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 711 B |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 1024 1024">
|
||||
<path d="M854.6 288.7c6 6 9.4 14.1 9.4 22.6V928c0 17.7-14.3 32-32 32H192c-17.7 0-32-14.3-32-32V96c0-17.7 14.3-32 32-32h424.7c8.5 0 16.7 3.4 22.7 9.4l215.2 215.3zM790.2 326L602 137.8V326h188.2zM296 136v64h64v-64h-64zm64 64v64h64v-64h-64zm-64 64v64h64v-64h-64zm64 64v64h64v-64h-64zm-64 64v64h64v-64h-64zm64 64v64h64v-64h-64zm-64 64v64h64v-64h-64zm0 64v160h128V584H296zm48 48h32v64h-32v-64z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 516 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" fill="currentColor" fill-rule="evenodd"><g><path d="M353 16H192.545v425.615c0 105.313-36.166 134.718-99.104 134.718-29.494 0-56.055-5.05-76.718-12.144L0 688.784C29.484 698.924 74.73 705 110.126 705 254.631 705 353 637.16 353 442.658zM702.49 0C547.26 0 449 88.126 449 204.609c0 100.313 75.67 163.12 185.696 203.629 79.577 28.358 111.03 53.695 111.03 95.218 0 45.579-36.358 74.96-105.13 74.96-63.868 0-121.849-21.311-161.146-42.573v-.042L449 662.427C486.361 683.735 556.12 705 631.741 705 813.52 705 898 607.753 898 493.293c0-97.243-54.036-160.035-170.937-204.627-86.47-34.432-122.813-53.669-122.813-97.227 0-34.45 31.446-65.834 96.3-65.834 63.834 0 107.728 21.445 133.307 34.632L872.193 32.05C832.103 14.461 778.109 0 702.49 0" transform="translate(63 160)"/></g></svg>
|
||||
|
After Width: | Height: | Size: 833 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" fill="currentColor" fill-rule="evenodd"><path d="M212.68 692.99s-34.325 19.95 24.343 26.6c71.1 8.05 107.351 7 185.632-7.874 0 0 20.665 12.949 49.385 24.149-175.475 75.074-397.184-4.375-259.36-42.875m-21.366-98.173s-38.352 28.35 20.315 34.475c75.83 7.875 135.897 8.4 239.571-11.55 0 0 14.36 14.525 36.952 22.4-212.427 62.123-448.846 5.075-296.838-45.325m180.73-166.422c43.256 49.699-11.384 94.498-11.384 94.498s109.804-56.7 59.368-127.573c-47.11-66.15-83.185-99.049 112.255-212.273.175 0-306.819 76.65-160.24 245.348M604.26 765.439s25.393 20.825-27.846 37.1c-101.397 30.625-421.7 39.9-510.664 1.225-32.048-13.825 28.02-33.25 46.934-37.275 19.613-4.2 30.997-3.5 30.997-3.5-35.551-25.025-229.94 49.175-98.771 70.35 357.605 58.1 652.165-26.075 559.35-67.9M229.142 493.144S66.1 531.818 171.35 545.818c44.482 5.95 133.095 4.55 215.58-2.275 67.423-5.6 135.196-17.85 135.196-17.85s-23.818 10.15-40.98 21.875C315.653 591.143-3.95 570.843 87.99 526.393c77.93-37.45 141.151-33.25 141.151-33.25M521.6 656.416c168.296-87.324 90.365-171.322 36.077-159.948-13.31 2.8-19.264 5.25-19.264 5.25s4.903-7.7 14.36-11.025c107.351-37.8 190.01 111.299-34.675 170.273 0-.175 2.627-2.45 3.502-4.55M420.028 0s93.166 93.1-88.438 236.246c-145.53 114.8-33.274 180.424 0 255.148-84.936-76.65-147.28-144.024-105.425-206.848C287.634 192.672 457.68 147.873 420.028 0m-174.25 893.188c161.466 10.325 409.443-5.775 415.222-82.075 0 0-11.208 28.875-133.445 51.975-137.824 25.9-307.87 22.925-408.567 6.3 0-.175 20.665 16.975 126.79 23.8" transform="translate(182 64)"/></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" fill="currentColor" fill-rule="evenodd"><g><path d="M443 678.5c0 15.74 12.76 28.5 28.5 28.5s28.5-12.76 28.5-28.5-12.76-28.5-28.5-28.5-28.5 12.76-28.5 28.5M300 121.5c0 15.74 12.76 28.5 28.5 28.5s28.5-12.76 28.5-28.5S344.24 93 328.5 93 300 105.76 300 121.5" transform="translate(112 112)"/><path d="M709.524 185.714h-95.238V90.476C614.286 40.571 573.714 0 523.81 0H276.19c-49.904 0-90.476 40.571-90.476 90.476v95.238H90.476C40.571 185.714 0 226.286 0 276.19v247.62c0 49.904 40.571 90.476 90.476 90.476h95.238v95.238c0 49.905 40.572 90.476 90.476 90.476h247.62c49.904 0 90.476-40.571 90.476-90.476v-95.238h95.238c49.905 0 90.476-40.572 90.476-90.476V276.19c0-49.904-40.571-90.476-90.476-90.476M90.476 557.143c-18.38 0-33.333-14.953-33.333-33.333V276.19c0-18.38 14.952-33.333 33.333-33.333h278.572c15.81 0 28.571-12.762 28.571-28.571 0-15.81-12.762-28.572-28.571-28.572h-126.19V90.476c0-18.38 14.952-33.333 33.332-33.333h247.62c18.38 0 33.333 14.952 33.333 33.333v256.476c0 13.524-10.953 24.477-24.476 24.477H267.333c-45.047 0-81.619 36.666-81.619 81.619v104.095zm652.381-33.333c0 18.38-14.952 33.333-33.333 33.333H430.952c-15.81 0-28.571 12.762-28.571 28.571 0 15.81 12.762 28.572 28.571 28.572h126.19v95.238c0 18.38-14.952 33.333-33.332 33.333H276.19c-18.38 0-33.333-14.952-33.333-33.333V453.048c0-13.524 10.953-24.477 24.476-24.477h265.334c45.047 0 81.619-36.666 81.619-81.619V242.857h95.238c18.38 0 33.333 14.953 33.333 33.333z" transform="translate(112 112)"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -1,5 +1,48 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.5 - 2026-09-09 (Meldwork-V1.0.5 prerelease)
|
||||
|
||||
### Local Agent Reliability
|
||||
|
||||
- Accept validated OpenClaw gateway configuration migrations without disabling path, permission, or credential isolation checks.
|
||||
- Keep installed Agents visible when credential or capability checks fail; isolate failed probes and refresh the catalog after current readiness settles.
|
||||
- Refresh native shell settings before manual detection and preserve supported proxy, CA, cloud Provider, and model-region settings without silently replacing a selected Provider.
|
||||
|
||||
### Group Conversations
|
||||
|
||||
- Route peer requests through explicit Agent decisions instead of interpreting prose mentions or media keywords as commands.
|
||||
- Persist AI task completion separately from individual invocation success and human adoption; support single-Agent automatic work and exact visible answers.
|
||||
- Resume human clarification gates after restart, reject conflicting responses, and prevent repeated responses from replaying calls.
|
||||
- Serialize workspace writers, recover interrupted writes through bound approvals, retain partial results, and send acknowledged session context incrementally.
|
||||
- Preserve completed and stopped task states after restart and show healthy contributions alongside failed participants.
|
||||
|
||||
### Verification And Distribution
|
||||
|
||||
- Frontend 343/343, desktop 1562/1562, and deterministic evaluation 6 cases / 18 results passed.
|
||||
- Web and desktop builds, packaging, real packaged Codex/OpenClaw group execution, cancellation, and restart acceptance passed on Apple silicon macOS.
|
||||
- DMG and ZIP are ad-hoc signed, without Apple Developer ID signing or notarization. No installed user application is replaced automatically.
|
||||
- Live coverage is limited to observed CLI versions and configurations; cloud credential forwarding is not cloud-account certification. Full evidence: `docs/releases/Meldwork-V1.0.5.md`.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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,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
|
||||
|
||||
|
After Width: | Height: | Size: 3.1 MiB |
@@ -1,11 +1,3 @@
|
||||
<!-- <p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="frontend/public/logos/meldwork-wordmark-v3-dark.svg">
|
||||
<source media="(prefers-color-scheme: light)" srcset="frontend/public/logos/meldwork-wordmark-v3.svg">
|
||||
<img src="frontend/public/logos/meldwork-wordmark-v3.svg" alt="Meldwork" width="360">
|
||||
</picture>
|
||||
</p> -->
|
||||
|
||||
<p align="center">
|
||||
<img src="frontend/public/logos/meldwork-readme-banner-en.png" alt="Meldwork README banner" width="100%">
|
||||
</p>
|
||||
@@ -14,113 +6,129 @@
|
||||
<strong>English</strong> · <a href="README.zh-CN.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
# Meldwork builds the organization layer for AI agents.
|
||||
# Meldwork — A local-first workspace for General Agents
|
||||
|
||||
**Most tools help humans run more agents. Meldwork is built for a different job: make multi-agent work legible, accountable, and reusable.**
|
||||
**Turn several General Agents into a reviewable working group. Meldwork gives every task a shared context, lets selected Agents investigate independently or discuss across rounds, and keeps the evidence and human decision visible before anything is adopted.**
|
||||
|
||||
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.
|
||||
Meldwork is a local-first Electron workspace for using the Agent tools you already have on your Mac. It supports focused conversations, independent concurrent responses, and Auto Discussion V4 for proposal, challenge, negotiated work, synthesis, and verification. Agents can work with text, files, images, media, Skills, and selected knowledge sources, so the same workspace can support research, analysis, writing, planning, review, and implementation.
|
||||
|
||||
The product is organized around a durable review record: **Case → Finding → Evidence → Decision → Disposition**. Meldwork keeps the task snapshot, Agent contributions, run state, artifacts, Human Gates, and adoption decision together in one local work cell. Workspace writes are opt-in and remain under the user's control.
|
||||
|
||||
<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.5/Meldwork-0.1.5-arm64.dmg"><strong>Download Meldwork V1.0.5 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
|
||||
## What Meldwork is for
|
||||
|
||||
Agent supply is growing quickly. The organization around it is not.
|
||||
Meldwork is useful when a decision benefits from more than one perspective and a clear record of why a result was adopted. Typical work includes:
|
||||
|
||||
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.
|
||||
- research and information synthesis across local Agent tools and selected knowledge sources;
|
||||
- product, market, operational, and technical analysis;
|
||||
- writing, planning, review, and structured document work;
|
||||
- multimodal work with local documents, images, audio, video, PDFs, and code or configuration files;
|
||||
- implementation and review tasks where findings, evidence, permissions, and the final adoption decision should remain inspectable.
|
||||
|
||||
Meldwork is focused on the work between them:
|
||||
## How a task moves through Meldwork
|
||||
|
||||
- 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 Agents and participants for this Case.
|
||||
2. **Scope** the goal, working directory, context, attachments, Skills, knowledge sources, and permissions.
|
||||
3. **Run** a direct conversation, Concurrent Responses, or Auto Discussion V4.
|
||||
4. **Review** the findings, evidence, artifacts, trace, and any Human Gate.
|
||||
5. **Adopt** the approved result or leave the Case unresolved for further work.
|
||||
|
||||
## 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 |
|
||||
| --- | --- | --- |
|
||||
| [](assets/meldwork-agent-discovery.png) | [](assets/meldwork-direct-multimodal.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 research, writing, analysis, or execution. |
|
||||
| **Concurrent Responses** | Selected Agents receive the same frozen task snapshot and return independent responses in stable order. | Comparing perspectives before choosing a path. |
|
||||
| **Auto Discussion V4** | Agents propose, challenge, negotiate responsibilities, execute agreed work, synthesize a candidate result, and verify it across bounded rounds. Native Agent sessions continue when supported. | Multi-step work that needs discussion, division of responsibility, and review. |
|
||||
|
||||
## What makes Meldwork different
|
||||
Participants are selected by the user. Automatic selection from a larger roster is outside the current preview.
|
||||
|
||||
### Harness design
|
||||
## The review record
|
||||
|
||||
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 treats collaboration as a decision process rather than a stream of chat messages:
|
||||
|
||||
<p align="center">
|
||||
<img src="frontend/public/logos/Harness-readme.png" alt="Meldwork Harness" width="100%">
|
||||
</p>
|
||||
- **Case** defines the question, scope, context, and permissions.
|
||||
- **Finding** records an Agent's claim, observation, or proposed action.
|
||||
- **Evidence** links the finding to a response, artifact, file, knowledge result, or other bounded reference.
|
||||
- **Decision** records what the evidence supports, what remains uncertain, and which path is selected.
|
||||
- **Disposition** records whether the result was accepted, revised, rejected, superseded, or left unresolved.
|
||||
|
||||
This is a comparison of working models, not a feature-scorecard claiming Meldwork should replace every tool.
|
||||
The Run Ledger preserves phase, participant, attempt, receipt, artifact, recovery, and Human Gate state. Diagnostic tool output and sensitive runtime details stay behind the Electron main-process boundary.
|
||||
|
||||
| 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 |
|
||||
## General Agent catalog
|
||||
|
||||
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.
|
||||
Meldwork detects and invokes compatible locally installed Agent commands. The catalog currently includes:
|
||||
|
||||
## Install
|
||||
<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>
|
||||
|
||||
The current release-validated target is Apple silicon macOS.
|
||||
Approved Agent Connectors and custom executable Agents use the desktop connector paths. The [desktop guide](desktop/README.md) contains the current adapter, installation, provider, and capability matrix.
|
||||
|
||||
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.
|
||||
## Context and local boundaries
|
||||
|
||||
Use only artifacts downloaded from the official Release.
|
||||
Meldwork runs as a local Electron application. Conversations, group configuration, run records, and app-owned attachments stay in the local user data directory. The renderer receives only validated snapshots through a narrow preload API; executable paths, credentials, native session references, Skill paths, and unrestricted shell access remain in the main process.
|
||||
|
||||
<details>
|
||||
<summary><strong>Build from source</strong></summary>
|
||||
Selected Agents may send prompts, attachments, or Skills to their configured model Provider. Knowledge access is explicit and bounded: the current implementation supports local Obsidian retrieval and CLI-owned Feishu or DingTalk access modes. Local-first describes where Meldwork stores and coordinates work; it does not promise that a configured Agent Provider is offline.
|
||||
|
||||
Requires Node.js 22.12 or newer and npm.
|
||||
## Download V1.0.5
|
||||
|
||||
~~~bash
|
||||
For Apple silicon macOS, download the [Meldwork V1.0.5 prerelease](https://github.com/Ryder-MHumble/Meldwork/releases/tag/Meldwork-V1.0.5) and choose [`Meldwork-0.1.5-arm64.dmg`](https://github.com/Ryder-MHumble/Meldwork/releases/download/Meldwork-V1.0.5/Meldwork-0.1.5-arm64.dmg). The release also includes a ZIP archive and SHA-256 manifest. Install at least one supported local Agent CLI before opening the app.
|
||||
|
||||
The preview artifacts are ad-hoc signed and not notarized with an Apple Developer ID. macOS may require **Open Anyway** under **System Settings → Privacy & Security** on first launch.
|
||||
|
||||
## 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>
|
||||
## V1.0.5
|
||||
|
||||
## Trust boundaries
|
||||
V1.0.5 improves local Agent readiness and recovery, preserves healthy participants when another Agent fails, keeps completed and stopped work visible after restart, and tightens group execution and output handling. It also updates the workspace preference flow, sidebar and titlebar details, and the Pi capability probe contract.
|
||||
|
||||
- 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.
|
||||
Verification for this prerelease includes 343/343 frontend tests, 1,562/1,562 desktop tests, six deterministic evaluation cases with 18 results, web and desktop builds, packaging, ZIP integrity, deep code-signature verification, and packaged macOS acceptance for group execution, cancellation, and restart recovery. The artifacts remain ad-hoc signed and unnotarized; live behavior still depends on the installed Agent CLI version, authentication, Provider, and capabilities.
|
||||
|
||||
Read the [architecture](architecture.md) and [Agent Connector contract](docs/agent-connector-sdk.md) for the public technical boundary.
|
||||
## Documentation
|
||||
|
||||
- [Architecture and product boundary](architecture.md)
|
||||
- [Desktop setup and Agent matrix](desktop/README.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).
|
||||
@@ -1,11 +1,3 @@
|
||||
<!-- <p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="frontend/public/logos/meldwork-wordmark-v3-dark.svg">
|
||||
<source media="(prefers-color-scheme: light)" srcset="frontend/public/logos/meldwork-wordmark-v3.svg">
|
||||
<img src="frontend/public/logos/meldwork-wordmark-v3.svg" alt="Meldwork" width="360">
|
||||
</picture>
|
||||
</p> -->
|
||||
|
||||
<p align="center">
|
||||
<img src="frontend/public/logos/meldwork-readme-banner-cn.png" alt="Meldwork README banner" width="100%">
|
||||
</p>
|
||||
@@ -14,115 +6,129 @@
|
||||
<a href="README.md">English</a> · <strong>简体中文</strong>
|
||||
</p>
|
||||
|
||||
# Meldwork 为 AI Agent 构建组织层
|
||||
# Meldwork:本地优先的 General Agent 工作空间
|
||||
|
||||
**多数工具是在帮人类管更多 Agent;Meldwork 解决的是另一件事:让多 Agent 协作变得可见、可追责、可复用。**
|
||||
**把多个 General Agent 组织成一个可复核的工作小组。Meldwork 为每个任务建立共享上下文,让选定的 Agent 独立调查或分轮讨论,并在采用结果前保留完整的证据和人工决策。**
|
||||
|
||||
当前预览版是一个面向已支持本地 Agent CLI 的本地工作单元。它把参与者、上下文、协作边界、运行状态和人类复核放在同一个工作空间里。已选 Agent 现在可以并发回复,也可以围绕同一目标先独立提案、再协商职责、分工执行、单写者整合并由其他 Agent 独立复核。
|
||||
Meldwork 是一个本地优先的 Electron 工作空间,用来连接你已经在电脑上使用的 Agent 工具。它支持直接会话、独立并发回复,以及包含提案、质询、职责协商、分工执行、整合和复核的 Auto Discussion V4。Agent 可以处理文字、文件、图片、音视频、Skill 和选定的知识源,因此同一个工作空间可以覆盖研究、分析、写作、规划、评审和实施。
|
||||
|
||||
产品围绕一条可持续复核的记录组织工作:**Case → Finding → Evidence → Decision → Disposition**。Meldwork 将任务快照、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.5/Meldwork-0.1.5-arm64.dmg"><strong>下载 Meldwork V1.0.5 Apple 芯片 macOS 版</strong></a>
|
||||
· <a href="architecture.md">架构</a>
|
||||
· <a href="LICENSE">许可证</a>
|
||||
</p>
|
||||
|
||||
## Agent workforce 缺少的那一层
|
||||
## Meldwork 适合什么工作
|
||||
|
||||
Agent 的供给正在快速增长,但围绕它们工作的组织能力并没有跟上。
|
||||
当一个决定需要多个视角,并且需要留下清晰的采用依据时,Meldwork 更适合:
|
||||
|
||||
今天,人类仍然需要自己选 Agent、搬运上下文、解释角色、处理冲突答案,并承担最终责任。并行管理器优化吞吐量,多 Agent 框架让开发者编写 Workflow,单 Agent 产品优化一个 Worker。
|
||||
- 研究与信息综合:结合本地 Agent 工具和明确选择的知识源;
|
||||
- 产品、市场、运营和技术分析;
|
||||
- 写作、规划、评审和结构化文档工作;
|
||||
- 处理本地文档、图片、音频、视频、PDF、代码和配置文件的多模态工作;
|
||||
- 需要保留发现、证据、权限、运行轨迹和最终采用决定的实施工作。
|
||||
|
||||
Meldwork 关注的是中间那层工作:
|
||||
## 一个任务如何在 Meldwork 中推进
|
||||
|
||||
- 一个共享 Goal 和明确验收边界;
|
||||
- 独立判断,而不是隐藏路由;
|
||||
- 明确的权限、责任、交接和停止条件;
|
||||
- 不随 Agent 切换而丢失的 Artifact 与 Evidence;
|
||||
- 由人类掌握的最终采用决定。
|
||||
1. **选择**本 Case 的 Agent 和参与者。
|
||||
2. **限定**目标、工作目录、上下文、附件、Skill、知识源和权限。
|
||||
3. **运行**直接会话、并发回复或 Auto Discussion V4。
|
||||
4. **复核**发现、证据、产物、运行轨迹和人工审批门。
|
||||
5. **采用**批准的结果,或保留为待解决状态继续处理。
|
||||
|
||||
## 协作模式
|
||||
|
||||
## Meldwork 正在构建什么
|
||||
|
||||
当前协作机制不是 Harness 暗中预设的 A → B → C 分配链。用户选定参与者后,Agent 先展开解空间、自主协商职责,再在 Harness 管理的上下文、权限、预算、恢复和证据边界内执行。
|
||||
|
||||
已选 Agent 的独立提案、交叉质询、职责协商、分工执行、整合和复核现在已经可用。从更大候选池自动选人组队、远程 Agent、企业治理和 Outcome 驱动的声誉系统仍是未来方向。
|
||||
|
||||
### 效果演示
|
||||
|
||||
| 本地 Agent 检测 | 单聊中的多模态工作 | 并发多 Agent 协作 |
|
||||
| 模式 | 运行方式 | 适合场景 |
|
||||
| --- | --- | --- |
|
||||
| [](assets/meldwork-agent-discovery.png) | [](assets/meldwork-direct-multimodal.png) | [](assets/meldwork-group-collaboration.png) |
|
||||
| 查看哪些受支持 Agent 已就绪。 | Prompt、文件、权限和兼容 Session 留在一起。 | 让已选 Agent 并发回复,或进入由 Agent 自主协商职责的协作流程。 |
|
||||
| **直接会话** | 一个已选 Agent 在适配器支持时保持对话和原生会话。 | 聚焦研究、写作、分析或执行。 |
|
||||
| **并发回复** | 已选 Agent 收到同一份冻结任务快照,并按稳定顺序返回独立回复。 | 在选择方案前比较不同视角。 |
|
||||
| **Auto Discussion V4** | Agent 先提出方案,再进行质询、职责协商、分工执行、候选结果整合和独立复核;支持时会在多轮中保持原生会话。 | 需要讨论、分工和复核的多步骤工作。 |
|
||||
|
||||
参与者始终由用户选择。从更大候选池自动选人组队不属于当前预览版。
|
||||
|
||||
## Meldwork 和别的产品有什么区别
|
||||
## 复核记录
|
||||
|
||||
### Harness设计
|
||||
Meldwork 将协作组织成一套决策过程,而不是一串聊天消息:
|
||||
|
||||
Harness 是把会话、上下文、适配器、流和持久化状态拴在一起的控制平面。它让一次任务可以跨会话、超时、恢复和 Agent 切换继续下去。
|
||||
- **Case**:定义问题、范围、上下文和权限;
|
||||
- **Finding**:记录 Agent 的判断、观察或行动建议;
|
||||
- **Evidence**:将 Finding 连接到回复、产物、文件、知识结果或其他受约束的依据;
|
||||
- **Decision**:记录证据支持的结论、未决问题和选定路径;
|
||||
- **Disposition**:记录结果已接受、修订、拒绝、被替代或仍未解决。
|
||||
|
||||
<p align="center">
|
||||
<img src="frontend/public/logos/Harness-readme.png" alt="Meldwork Harness" width="100%">
|
||||
</p>
|
||||
Run Ledger 会保留阶段、参与者、尝试、回执、产物、恢复状态和人工审批门。诊断工具输出与敏感运行信息留在 Electron 主进程边界内。
|
||||
|
||||
这张表比较的是不同工作范式,不是一张声称 Meldwork 应当替代所有工具的功能勾选表。
|
||||
## General Agent 目录
|
||||
|
||||
| 范式 | 例子 | 最擅长什么 | 主要代价 | 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 的组织层 |
|
||||
当适配器和 CLI 版本兼容时,Meldwork 会检测并调用已安装的 Agent 命令。当前目录包括:
|
||||
|
||||
如果一个 Agent 和一个工作面已经足够,直接使用原生工具。只有当交接、独立判断、权限、证据和验收成为问题时,才需要 Meldwork。
|
||||
<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>
|
||||
|
||||
## 安装
|
||||
已批准的 Agent Connector 和自定义可执行 Agent 使用桌面端的连接器入口。完整的适配器、安装、Provider 和能力矩阵见[桌面端指南](desktop/README.md)。
|
||||
|
||||
当前经过发布验证的目标是 Apple 芯片 macOS。
|
||||
## 上下文与本地边界
|
||||
|
||||
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。
|
||||
Meldwork 作为本地 Electron 应用运行。对话、群组配置、运行记录和应用管理的附件保存在本地用户数据目录。渲染进程只通过受约束的 preload API 获取校验后的状态;可执行路径、凭据、原生会话引用、Skill 路径和任意 Shell 权限都留在主进程中。
|
||||
|
||||
请只使用官方 Release 下载的产物。
|
||||
选定的 Agent 仍可能把 Prompt、附件或 Skill 发送给其配置的模型 Provider。知识访问必须明确选择并受边界约束:当前实现支持本地 Obsidian 检索,以及由本地 CLI 管理的飞书或钉钉访问模式。Local-first 描述的是 Meldwork 的数据和协作位置,不代表已配置的 Agent Provider 一定离线。
|
||||
|
||||
<details>
|
||||
<summary><strong>从源码运行</strong></summary>
|
||||
## 下载 V1.0.5
|
||||
|
||||
需要 Node.js 22.12 或更高版本以及 npm。
|
||||
Apple 芯片 macOS 用户请前往 [Meldwork V1.0.5 预发布页](https://github.com/Ryder-MHumble/Meldwork/releases/tag/Meldwork-V1.0.5),下载 [`Meldwork-0.1.5-arm64.dmg`](https://github.com/Ryder-MHumble/Meldwork/releases/download/Meldwork-V1.0.5/Meldwork-0.1.5-arm64.dmg)。发布页同时提供 ZIP 压缩包和 SHA-256 校验文件。启动应用前,请至少安装一个受支持的本地 Agent CLI。
|
||||
|
||||
~~~bash
|
||||
该预览版安装包使用 ad-hoc 临时签名,未使用 Apple Developer ID 签名,也未进行公证。macOS 首次启动时可能需要在“系统设置 → 隐私与安全性”中选择“仍要打开 / Open Anyway”。
|
||||
|
||||
## 从源码运行
|
||||
|
||||
前置条件: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>
|
||||
## V1.0.5
|
||||
|
||||
## 信任边界
|
||||
V1.0.5 改进了本地 Agent 的就绪检测与恢复,在单个 Agent 失败时保留健康参与者,重启后继续显示已完成和已停止的工作,并加强了群组执行与结果导入。同时更新了工作区偏好、侧栏和标题栏细节,以及 Pi 能力探测契约。
|
||||
|
||||
- Meldwork 会话和编排状态保存在本地。
|
||||
- 模型请求仍然按照你选择的 Agent 和 Provider 发出;Local-first 不等于所有模型离线运行。
|
||||
- 兼容环境使用操作系统安全存储保存凭据。
|
||||
- 工作目录写入必须显式开启。
|
||||
- Renderer 不暴露 raw chain-of-thought、可执行路径、Secret 或不受限命令输出。
|
||||
- Cloud、Channel、自动选人组队、企业治理和 Outcome Network 不是当前发布承诺。
|
||||
本预览版已完成 343/343 前端测试、1,562/1,562 桌面端测试、6 个确定性评估场景共 18 条结果验证,并通过 Web 与桌面构建、打包、ZIP 完整性、深度代码签名校验,以及 Apple 芯片 macOS 上的群组执行、取消和重启恢复验收。安装包仍为 ad-hoc 临时签名且未公证;实际运行结果仍取决于本机 Agent CLI 版本、鉴权、Provider 和能力。
|
||||
|
||||
公开技术边界见[架构](architecture.md)和 [Agent Connector 契约](docs/agent-connector-sdk.md)。
|
||||
## 文档
|
||||
|
||||
- [架构与产品边界](architecture.md)
|
||||
- [桌面端设置与 Agent 矩阵](desktop/README.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)。
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Product Boundary
|
||||
|
||||
Meldwork is a local Electron desktop product for direct and multi-Agent work with explicitly selected Skill, image, and knowledge-source context. It has no account system, tenant model, required application server, remote conversation store, email delivery, scheduled jobs, or public SEO surface. Optional Agent, Cloud, and Channel Connectors run behind the Electron main-process trust boundary and are disabled until explicitly configured.
|
||||
Meldwork is a local Electron desktop product for multi-agent orchestration and review with explicitly selected Skill, image, and knowledge-source context. It has no account system, tenant model, required application server, remote conversation store, email delivery, scheduled jobs, or hosted web surface. Optional Agent, Cloud, and Channel Connectors run behind the Electron main-process trust boundary and are disabled until explicitly configured. The landing page at https://ryder-mhumble.github.io/Meldwork/ is the public marketing surface for search engine and AI assistant discoverability.
|
||||
|
||||
The supported product surface is the packaged Electron application. The frontend is a renderer bundle; without the local preload bridge it shows a desktop-required state and cannot operate the workspace.
|
||||
|
||||
@@ -103,7 +103,7 @@ There is no Meldwork-hosted remote data store or application account namespace.
|
||||
|
||||
- No email sending, so there is no `emails.md`.
|
||||
- No cron or scheduled background work, so there is no `cron.md`.
|
||||
- No supported public or indexable routes, so there is no `seo.md`.
|
||||
- No supported public or indexable routes in the app itself, so there is no `seo.md`. The landing page at https://ryder-mhumble.github.io/Meldwork/ serves as the public discoverability surface for search engines and AI assistants.
|
||||
|
||||
## Related Documents
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"mode": "chat"}
|
||||
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 368 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "meldwork-desktop",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meldwork-desktop",
|
||||
"version": "0.1.3",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"version": "0.1.5",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.23.0",
|
||||
"@streamparser/json": "0.0.22",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "meldwork-desktop",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.5",
|
||||
"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"
|
||||
},
|
||||
@@ -38,6 +38,7 @@
|
||||
"productName": "Meldwork",
|
||||
"artifactName": "Meldwork-${version}-${arch}.${ext}",
|
||||
"afterPack": "scripts/after-pack.cjs",
|
||||
"afterAllArtifactBuild": "scripts/after-all-artifact-build.cjs",
|
||||
"files": [
|
||||
"src/**/*",
|
||||
"samples/**/*",
|
||||
@@ -59,6 +60,10 @@
|
||||
{
|
||||
"from": "../COMMERCIAL_USE.md",
|
||||
"to": "COMMERCIAL_USE.md"
|
||||
},
|
||||
{
|
||||
"from": "build/dmg-background-ai-v4.tiff",
|
||||
"to": "dmg-background.tiff"
|
||||
}
|
||||
],
|
||||
"mac": {
|
||||
@@ -70,21 +75,23 @@
|
||||
]
|
||||
},
|
||||
"dmg": {
|
||||
"background": "build/dmg-background-ai-v2.png",
|
||||
"background": "build/dmg-background-ai-v4.png",
|
||||
"icon": null,
|
||||
"writeUpdateInfo": false,
|
||||
"window": {
|
||||
"width": 540,
|
||||
"height": 380
|
||||
"width": 640,
|
||||
"height": 420
|
||||
},
|
||||
"iconSize": 128,
|
||||
"iconSize": 112,
|
||||
"contents": [
|
||||
{
|
||||
"x": 145,
|
||||
"y": 210,
|
||||
"x": 110,
|
||||
"y": 205,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"x": 395,
|
||||
"y": 210,
|
||||
"x": 520,
|
||||
"y": 205,
|
||||
"type": "link",
|
||||
"path": "/Applications"
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
@@ -0,0 +1,126 @@
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const { execFileSync } = require('node:child_process')
|
||||
|
||||
const ROOT_HIDDEN_RESOURCES = ['.background.tiff', '.VolumeIcon.icns']
|
||||
|
||||
function selectDmgArtifacts(artifactPaths) {
|
||||
return artifactPaths.filter(artifactPath => artifactPath.endsWith('.dmg'))
|
||||
}
|
||||
|
||||
function findBundledPython(cacheRoot = process.env.ELECTRON_BUILDER_CACHE) {
|
||||
const root = cacheRoot || path.join(os.homedir(), 'Library', 'Caches', 'electron-builder')
|
||||
const pending = [root]
|
||||
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop()
|
||||
let entries
|
||||
try {
|
||||
entries = fs.readdirSync(current, { withFileTypes: true })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const candidate = path.join(current, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
pending.push(candidate)
|
||||
} else if (
|
||||
entry.isFile()
|
||||
&& /^python3(?:\.\d+)?$/.test(entry.name)
|
||||
&& path.basename(current) === 'bin'
|
||||
&& candidate.includes(`${path.sep}dmg-builder@`)
|
||||
) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Unable to locate dmg-builder's bundled Python under ${root}`)
|
||||
}
|
||||
|
||||
function parseAttachedVolume(plist, workDir, run = execFileSync) {
|
||||
const plistPath = path.join(workDir, 'attach.plist')
|
||||
fs.writeFileSync(plistPath, plist)
|
||||
const parsed = JSON.parse(run('plutil', ['-convert', 'json', '-o', '-', plistPath], {
|
||||
encoding: 'utf8',
|
||||
}))
|
||||
const mounted = parsed['system-entities'].find(entity => entity['mount-point'])
|
||||
if (!mounted) {
|
||||
throw new Error('hdiutil did not return a mounted DMG volume')
|
||||
}
|
||||
return { device: mounted['dev-entry'], mountPath: mounted['mount-point'] }
|
||||
}
|
||||
|
||||
function repointDmgBackground(dmgPath, options = {}) {
|
||||
const run = options.execFile || execFileSync
|
||||
const pythonPath = options.pythonPath || findBundledPython(options.cacheRoot)
|
||||
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-dmg-'))
|
||||
const readWriteDmg = path.join(workDir, 'writable.dmg')
|
||||
const repackedDmg = `${dmgPath}.repacked-${process.pid}.dmg`
|
||||
let device
|
||||
|
||||
try {
|
||||
run('hdiutil', ['convert', dmgPath, '-format', 'UDRW', '-o', readWriteDmg], {
|
||||
stdio: 'pipe',
|
||||
})
|
||||
const attached = parseAttachedVolume(run('hdiutil', [
|
||||
'attach', readWriteDmg, '-nobrowse', '-readwrite', '-plist',
|
||||
], { encoding: 'utf8' }), workDir, run)
|
||||
device = attached.device
|
||||
|
||||
const backgroundPath = path.join(
|
||||
attached.mountPath,
|
||||
'Meldwork.app',
|
||||
'Contents',
|
||||
'Resources',
|
||||
'dmg-background.tiff',
|
||||
)
|
||||
if (!fs.existsSync(backgroundPath)) {
|
||||
throw new Error(`Packaged DMG background is missing: ${backgroundPath}`)
|
||||
}
|
||||
|
||||
run(pythonPath, [
|
||||
path.join(__dirname, 'repoint-dmg-background.py'),
|
||||
path.join(attached.mountPath, '.DS_Store'),
|
||||
backgroundPath,
|
||||
], { stdio: 'pipe' })
|
||||
|
||||
for (const resource of ROOT_HIDDEN_RESOURCES) {
|
||||
fs.rmSync(path.join(attached.mountPath, resource), { force: true })
|
||||
}
|
||||
run('/usr/bin/SetFile', ['-a', 'c', attached.mountPath], { stdio: 'pipe' })
|
||||
run('/bin/sync', [], { stdio: 'pipe' })
|
||||
run('hdiutil', ['detach', device], { stdio: 'pipe' })
|
||||
device = null
|
||||
|
||||
run('hdiutil', [
|
||||
'convert', readWriteDmg, '-format', 'UDZO', '-imagekey', 'zlib-level=9', '-o', repackedDmg,
|
||||
], { stdio: 'pipe' })
|
||||
fs.renameSync(repackedDmg, dmgPath)
|
||||
} finally {
|
||||
if (device) {
|
||||
try {
|
||||
run('hdiutil', ['detach', '-force', device], { stdio: 'pipe' })
|
||||
} catch {}
|
||||
}
|
||||
fs.rmSync(repackedDmg, { force: true })
|
||||
fs.rmSync(workDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function afterAllArtifactBuild(buildResult) {
|
||||
for (const dmgPath of selectDmgArtifacts(buildResult.artifactPaths)) {
|
||||
repointDmgBackground(dmgPath)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
afterAllArtifactBuild.ROOT_HIDDEN_RESOURCES = ROOT_HIDDEN_RESOURCES
|
||||
afterAllArtifactBuild.selectDmgArtifacts = selectDmgArtifacts
|
||||
afterAllArtifactBuild.findBundledPython = findBundledPython
|
||||
afterAllArtifactBuild.parseAttachedVolume = parseAttachedVolume
|
||||
afterAllArtifactBuild.repointDmgBackground = repointDmgBackground
|
||||
|
||||
module.exports = afterAllArtifactBuild
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env swift
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
let arguments = CommandLine.arguments
|
||||
guard arguments.count == 3 else {
|
||||
fputs("Usage: render-dmg-background.swift <source.png> <output.png>\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
let sourceURL = URL(fileURLWithPath: arguments[1])
|
||||
let outputURL = URL(fileURLWithPath: arguments[2])
|
||||
guard let source = NSImage(contentsOf: sourceURL) else {
|
||||
fputs("Unable to read source image\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
guard let bitmap = NSBitmapImageRep(
|
||||
bitmapDataPlanes: nil,
|
||||
pixelsWide: 1280,
|
||||
pixelsHigh: 840,
|
||||
bitsPerSample: 8,
|
||||
samplesPerPixel: 4,
|
||||
hasAlpha: true,
|
||||
isPlanar: false,
|
||||
colorSpaceName: .deviceRGB,
|
||||
bytesPerRow: 0,
|
||||
bitsPerPixel: 0
|
||||
) else {
|
||||
fputs("Unable to create output bitmap\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: bitmap)
|
||||
source.draw(
|
||||
in: NSRect(x: 0, y: 0, width: 1280, height: 840),
|
||||
from: NSRect(x: 0, y: 4, width: 1280, height: 840),
|
||||
operation: .copy,
|
||||
fraction: 1
|
||||
)
|
||||
|
||||
let centered = NSMutableParagraphStyle()
|
||||
centered.alignment = .center
|
||||
|
||||
let title = "INSTALL MELDWORK" as NSString
|
||||
title.draw(
|
||||
in: NSRect(x: 0, y: 712, width: 1280, height: 52),
|
||||
withAttributes: [
|
||||
.font: NSFont(name: "DIN Alternate Bold", size: 38) ?? NSFont.boldSystemFont(ofSize: 38),
|
||||
.foregroundColor: NSColor(white: 0.96, alpha: 0.94),
|
||||
.kern: 4.5,
|
||||
.paragraphStyle: centered,
|
||||
]
|
||||
)
|
||||
|
||||
let explanation = "DRAG MELDWORK INTO APPLICATIONS / YOUR WORKSPACE STAYS LOCAL" as NSString
|
||||
explanation.draw(
|
||||
in: NSRect(x: 0, y: 675, width: 1280, height: 30),
|
||||
withAttributes: [
|
||||
.font: NSFont(name: "Menlo-Regular", size: 14) ?? NSFont.monospacedSystemFont(ofSize: 14, weight: .regular),
|
||||
.foregroundColor: NSColor(white: 0.82, alpha: 0.74),
|
||||
.kern: 1.5,
|
||||
.paragraphStyle: centered,
|
||||
]
|
||||
)
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
|
||||
guard let png = bitmap.representation(using: .png, properties: [:]) else {
|
||||
fputs("Unable to encode PNG\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
try png.write(to: outputURL)
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env python3
|
||||
import sys
|
||||
|
||||
from ds_store import DSStore
|
||||
from mac_alias import Alias
|
||||
|
||||
|
||||
def main():
|
||||
ds_store_path, background_path = sys.argv[1:3]
|
||||
with DSStore.open(ds_store_path, "r+") as store:
|
||||
icon_view = store["."]["icvp"]
|
||||
icon_view["backgroundImageAlias"] = Alias.for_file(background_path).to_bytes()
|
||||
store["."]["icvp"] = icon_view
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -57,7 +57,9 @@ const AGENT_COMPATIBILITY = Object.freeze({
|
||||
]),
|
||||
]),
|
||||
pi: exactProfile('0.84.2', [
|
||||
probe('pi-cli', ['--help'], []),
|
||||
probe('pi-cli', ['--help'], [
|
||||
'--mode', '--print', '--session', '--tools', '--approve', '--no-approve', 'json',
|
||||
]),
|
||||
]),
|
||||
kimi: profile('0.19.2', '0.32.0', [
|
||||
probe('kimi-stream', ['--help'], [
|
||||
@@ -99,11 +101,13 @@ const AGENT_COMPATIBILITY = Object.freeze({
|
||||
]),
|
||||
})
|
||||
|
||||
function probe(id, args, requiredText) {
|
||||
function probe(id, args, requiredText, validateOutput = null, timeout = null) {
|
||||
return Object.freeze({
|
||||
id,
|
||||
args: Object.freeze([...args]),
|
||||
requiredText: Object.freeze([...requiredText]),
|
||||
validateOutput,
|
||||
timeout,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -426,7 +426,7 @@ function importAgentOutputs(input, attachmentStore) {
|
||||
&& !Array.isArray(input.baseline.files)
|
||||
? input.baseline.files
|
||||
: {}
|
||||
const candidates = outputFiles(safeOutputDirectory(workdirRealPath))
|
||||
const controlledCandidates = outputFiles(safeOutputDirectory(workdirRealPath))
|
||||
.filter(file => !sameFileState(baselineFiles[file.name], file))
|
||||
.filter(file => Math.max(file.mtimeMs, file.ctimeMs) >= startedAt - 2000)
|
||||
.sort((left, right) => (
|
||||
@@ -434,6 +434,25 @@ function importAgentOutputs(input, attachmentStore) {
|
||||
|| left.name.localeCompare(right.name)
|
||||
))
|
||||
|
||||
const reportedCandidates = []
|
||||
const reportedPaths = Array.isArray(input.reportedPaths) ? input.reportedPaths : []
|
||||
for (const reportedPath of reportedPaths.slice(0, MAX_SCANNED_ENTRIES)) {
|
||||
if (typeof reportedPath !== 'string' || !path.isAbsolute(reportedPath)) continue
|
||||
try {
|
||||
const realPath = fs.realpathSync(reportedPath)
|
||||
const stat = fs.statSync(realPath)
|
||||
const extension = path.extname(realPath).toLowerCase()
|
||||
if (!stat.isFile() || !SUPPORTED_EXTENSIONS.has(extension)
|
||||
|| stat.size > 64 * 1024 * 1024 || !isInside(workdirRealPath, realPath)) continue
|
||||
reportedCandidates.push({
|
||||
name: path.basename(realPath), path: realPath, size: stat.size,
|
||||
mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs,
|
||||
})
|
||||
} catch { /* reported paths are best effort */ }
|
||||
}
|
||||
const candidates = [...controlledCandidates, ...reportedCandidates]
|
||||
.filter((file, index, all) => all.findIndex(candidate => candidate.path === file.path) === index)
|
||||
|
||||
const imported = []
|
||||
if (input.signal?.aborted) return imported
|
||||
for (const file of candidates) {
|
||||
|
||||
@@ -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|(?:invalid|expired|missing)\s+(?:access\s+token|token|credentials?|api[ _-]?key|authentication))(?:\s*\(request[ _-]?id:\s*[^()]{1,160}\))?\.?$/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,
|
||||
}
|
||||
@@ -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({
|
||||
@@ -889,6 +886,11 @@ async function runAcpTurn(runtime, prompt, options, spec, profile, persistent) {
|
||||
? 'timeout'
|
||||
: normalized.failure?.category === 'cancellation' ? 'stopped' : 'failed',
|
||||
})
|
||||
if (promptStarted && options.sessionRef
|
||||
&& runtime.replyState.bytes === 0
|
||||
&& normalized.message === 'LOCAL_AGENT_REFUSED') {
|
||||
throw agentExecutionError('LOCAL_AGENT_SESSION_INVALID')
|
||||
}
|
||||
if (!promptStarted && normalized.message !== 'LOCAL_AGENT_SESSION_INVALID') {
|
||||
allowAcpSetupFallback(normalized)
|
||||
}
|
||||
@@ -955,9 +957,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 +968,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 +1002,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)
|
||||
|
||||
@@ -15,6 +15,7 @@ const {
|
||||
parseMimoOutput,
|
||||
parseOpenCodeOutput,
|
||||
parseOpenCodeReviewOutput,
|
||||
parsePiOutput,
|
||||
parseWorkBuddyOutput,
|
||||
readHermesFinalResponse,
|
||||
readHermesMessageWatermark,
|
||||
@@ -122,6 +123,7 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
? options.hermesAcpAvailable
|
||||
: agent.acpAvailable
|
||||
const spec = invocation(agent.kind, agent.executable, workdir, sessionRef, {
|
||||
platform,
|
||||
sandbox: options.sandbox,
|
||||
provider: options.provider,
|
||||
attachments: options.attachments,
|
||||
@@ -312,7 +314,7 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
if (terminalObserved || !event || typeof event !== 'object') return
|
||||
const terminal = (
|
||||
(['workbuddy', 'claude', 'qwen'].includes(agent.kind) && event.type === 'result')
|
||||
|| (agent.kind === 'pi' && ['turn_end', 'agent_end'].includes(event.type))
|
||||
|| (agent.kind === 'pi' && event.type === 'agent_end')
|
||||
|| (agent.kind === 'codex' && event.type === 'turn.completed')
|
||||
|| (['mimo', 'opencode'].includes(agent.kind) && event.type === 'step_finish')
|
||||
|| (agent.kind === 'gemini' && event.type === 'result')
|
||||
@@ -416,6 +418,7 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
})
|
||||
let result = structuredOutput.end({
|
||||
sessionRef: nextSessionRef,
|
||||
prompt,
|
||||
})
|
||||
const rawStdout = stdout.text()
|
||||
const structuredDetail = structuredCliError(rawStdout)
|
||||
@@ -499,6 +502,7 @@ module.exports = {
|
||||
parseMimoOutput,
|
||||
parseOpenCodeOutput,
|
||||
parseOpenCodeReviewOutput,
|
||||
parsePiOutput,
|
||||
parseWorkBuddyOutput,
|
||||
prepareCommand,
|
||||
readHermesFinalResponse,
|
||||
|
||||
@@ -3,6 +3,7 @@ const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const { promisify } = require('node:util')
|
||||
const { networkEnvironment } = require('./cli-network-environment.cjs')
|
||||
const {
|
||||
assessAgentVersion,
|
||||
capabilityProbes,
|
||||
@@ -26,6 +27,8 @@ const AGENT_PROFILES = {
|
||||
}
|
||||
const ALLOWED_KINDS = Object.keys(AGENT_PROFILES)
|
||||
const DEFAULT_WINDOWS_PATHEXT = ['.COM', '.EXE', '.BAT', '.CMD']
|
||||
const CAPABILITY_CACHE_TTL_MS = 5 * 60 * 1000
|
||||
const capabilityCache = new Map()
|
||||
const SYSTEM_CHILD_ENV_KEYS = Object.freeze([
|
||||
'HOME', 'USER', 'LOGNAME', 'SHELL',
|
||||
'TMPDIR', 'TMP', 'TEMP',
|
||||
@@ -38,7 +41,7 @@ const SYSTEM_CHILD_ENV_KEYS = Object.freeze([
|
||||
'APPDATA', 'LOCALAPPDATA', 'PROGRAMDATA',
|
||||
'PROGRAMFILES', 'PROGRAMFILES(X86)', 'PROGRAMW6432',
|
||||
'OS', 'PROCESSOR_ARCHITECTURE', 'PROCESSOR_IDENTIFIER', 'NUMBER_OF_PROCESSORS',
|
||||
'SSL_CERT_FILE', 'SSL_CERT_DIR', 'NODE_EXTRA_CA_CERTS',
|
||||
'CODEX_HOME', 'CLAUDE_CONFIG_DIR', 'PI_CODING_AGENT_DIR',
|
||||
])
|
||||
|
||||
function envValue(env, name) {
|
||||
@@ -47,7 +50,7 @@ function envValue(env, name) {
|
||||
}
|
||||
|
||||
function systemChildEnvironment(sourceEnv = process.env, platform = process.platform) {
|
||||
const env = {}
|
||||
const env = networkEnvironment(sourceEnv, platform)
|
||||
for (const name of SYSTEM_CHILD_ENV_KEYS) {
|
||||
const value = envValue(sourceEnv, name)
|
||||
if (typeof value === 'string' && value) env[name] = value
|
||||
@@ -334,27 +337,50 @@ async function probeAgentCapabilities(kind, executable, options = {}) {
|
||||
...systemChildEnvironment(env, platform),
|
||||
PATH: searchPath(options),
|
||||
}
|
||||
let identity = ''
|
||||
try {
|
||||
const stat = (options.statFn || fs.statSync)(executable)
|
||||
identity = JSON.stringify([
|
||||
kind, executable, options.version || '', stat.dev, stat.ino,
|
||||
stat.size, stat.mtimeMs, stat.ctimeMs,
|
||||
])
|
||||
} catch { /* caching requires a verifiable executable identity */ }
|
||||
const cacheKey = `${kind}:${executable}`
|
||||
const prior = capabilityCache.get(cacheKey)
|
||||
const recentCompatible = identity && prior?.identity === identity
|
||||
&& Date.now() - prior.checkedAt < CAPABILITY_CACHE_TTL_MS
|
||||
const results = await Promise.all(capabilityProbes(kind).map(async (probe) => {
|
||||
try {
|
||||
const command = prepareCommandFn(executable, probe.args, options)
|
||||
const result = await execFileFn(command.command, command.args, {
|
||||
timeout: 8000,
|
||||
windowsHide: true,
|
||||
env: childEnv,
|
||||
})
|
||||
return {
|
||||
probe,
|
||||
output: `${result.stdout || ''}\n${result.stderr || ''}`,
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
probe,
|
||||
error: true,
|
||||
for (const timeout of probe.timeout ? [probe.timeout] : [8000, 16000]) {
|
||||
try {
|
||||
const command = prepareCommandFn(executable, probe.args, options)
|
||||
const result = await execFileFn(command.command, command.args, {
|
||||
timeout,
|
||||
windowsHide: true,
|
||||
env: childEnv,
|
||||
})
|
||||
return { probe, output: `${result.stdout || ''}\n${result.stderr || ''}` }
|
||||
} catch (error) {
|
||||
const timedOut = error?.killed === true || error?.code === 'ETIMEDOUT'
|
||||
if (timedOut && timeout === 8000) continue
|
||||
return { probe, error: true, timedOut }
|
||||
}
|
||||
}
|
||||
}))
|
||||
const failed = results.find(result => !result.timedOut && (result.error
|
||||
|| result.probe.requiredText.some(value => !result.output.includes(value))
|
||||
|| (result.probe.validateOutput && !result.probe.validateOutput(result.output))))
|
||||
if (failed) capabilityCache.delete(cacheKey)
|
||||
for (const result of results) {
|
||||
const { probe } = result
|
||||
if (result.timedOut && !failed) {
|
||||
if (recentCompatible) continue
|
||||
return {
|
||||
compatibilityState: 'unknown',
|
||||
incompatibilityReason: 'LOCAL_AGENT_CAPABILITY_PROBE_TIMEOUT',
|
||||
incompatibilityProbe: probe.id,
|
||||
}
|
||||
}
|
||||
if (result.timedOut) continue
|
||||
if (result.error) {
|
||||
return {
|
||||
compatibilityState: 'incompatible',
|
||||
@@ -372,6 +398,9 @@ async function probeAgentCapabilities(kind, executable, options = {}) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (identity && !results.some(result => result.timedOut)) {
|
||||
capabilityCache.set(cacheKey, { identity, checkedAt: Date.now() })
|
||||
}
|
||||
return {
|
||||
compatibilityState: 'compatible',
|
||||
incompatibilityReason: '',
|
||||
@@ -402,18 +431,17 @@ async function inspectAgentCandidate(kind, executable, options, childEnv) {
|
||||
version: identified?.line || '',
|
||||
versionIdentified: Boolean(identified),
|
||||
}
|
||||
} catch { /* a broken shim is not a usable CLI */ }
|
||||
} catch { /* capability probes can still establish whether this installation works */ }
|
||||
return { succeeded: false, version: '', versionIdentified: false }
|
||||
})()
|
||||
|
||||
const { succeeded: versionCommandSucceeded, version } = await versionTask
|
||||
if (!versionCommandSucceeded) return null
|
||||
const { version } = await versionTask
|
||||
const versionCompatibility = assessAgentVersion(kind, version)
|
||||
const capabilityTask = Promise.resolve().then(async () => {
|
||||
const result = await (options.probeAgentCapabilitiesFn || probeAgentCapabilities)(
|
||||
kind,
|
||||
executable,
|
||||
{ ...options, childEnv },
|
||||
{ ...options, childEnv, version },
|
||||
)
|
||||
return result && typeof result === 'object' ? result : incompatibleCapabilities
|
||||
}).catch(() => incompatibleCapabilities)
|
||||
@@ -433,7 +461,7 @@ async function inspectAgentCandidate(kind, executable, options, childEnv) {
|
||||
})()
|
||||
: Promise.resolve(undefined)
|
||||
const [capabilityCompatibility, acpAvailable] = await Promise.all([capabilityTask, acpTask])
|
||||
const compatibility = capabilityCompatibility.compatibilityState === 'incompatible'
|
||||
const compatibility = capabilityCompatibility.compatibilityState !== 'compatible'
|
||||
? { ...versionCompatibility, ...capabilityCompatibility }
|
||||
: {
|
||||
...versionCompatibility,
|
||||
|
||||
@@ -73,9 +73,10 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
|
||||
}
|
||||
}
|
||||
if (kind === 'hermes') {
|
||||
const legacySessionRef = options.sessionTransport === 'acp' ? '' : sessionRef
|
||||
// Hermes uses the same native conversation identifier across its ACP and
|
||||
// legacy transports. Keep it when an attachment requires the legacy path.
|
||||
const legacySessionRef = sessionRef
|
||||
const useAcp = options.invocationTransport !== 'legacy'
|
||||
&& !attachments.length
|
||||
&& options.hermesAcpAvailable !== false
|
||||
&& (!sessionRef || options.sessionTransport === 'acp')
|
||||
if (useAcp) {
|
||||
@@ -144,6 +145,7 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
|
||||
'--output-format', 'stream-json',
|
||||
'--include-partial-messages',
|
||||
'--permission-mode', options.sandbox === 'workspace-write' ? 'acceptEdits' : 'plan',
|
||||
...(options.sandbox === 'workspace-write' ? ['--dangerously-skip-permissions'] : []),
|
||||
...(maxTurns ? ['--max-turns', String(maxTurns)] : []),
|
||||
...(sessionRef ? ['--resume', sessionRef] : []),
|
||||
],
|
||||
@@ -156,7 +158,9 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
|
||||
command: executable,
|
||||
args: [
|
||||
'--mode', 'json', '--print',
|
||||
...(options.sandbox === 'workspace-write' ? ['--approve'] : ['--no-approve']),
|
||||
...(options.sandbox === 'workspace-write'
|
||||
? ['--approve']
|
||||
: ['--no-approve', '--tools', 'read,grep,find,ls']),
|
||||
...(sessionRef ? ['--session', sessionRef] : []),
|
||||
],
|
||||
promptArg: true,
|
||||
@@ -185,7 +189,7 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
|
||||
}
|
||||
}
|
||||
if (kind === 'mimo') {
|
||||
if (options.invocationTransport !== 'json') {
|
||||
if (!attachments.length && options.invocationTransport !== 'json') {
|
||||
return {
|
||||
command: executable,
|
||||
args: ['acp', '--pure', '--cwd', workdir],
|
||||
@@ -214,6 +218,24 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
|
||||
'--include-partial-messages',
|
||||
'--verbose',
|
||||
'--permission-mode', options.sandbox === 'workspace-write' ? 'acceptEdits' : 'plan',
|
||||
...(options.provider?.id === 'anthropic' ? [
|
||||
'--settings', JSON.stringify({
|
||||
// Native settings.env overrides process env. The helper keeps the key out of argv.
|
||||
apiKeyHelper: (options.platform || process.platform) === 'win32'
|
||||
? 'powershell.exe -NoLogo -NoProfile -NonInteractive -Command "[Console]::Write([Environment]::GetEnvironmentVariable(\'MELDWORK_PROVIDER_API_KEY\'))"'
|
||||
: 'printf \'%s\' "$MELDWORK_PROVIDER_API_KEY"',
|
||||
env: {
|
||||
CLAUDE_CODE_USE_BEDROCK: '0', CLAUDE_CODE_USE_VERTEX: '0',
|
||||
CLAUDE_CODE_USE_FOUNDRY: '0', CLAUDE_CODE_USE_MANTLE: '0',
|
||||
ANTHROPIC_BASE_URL: options.provider.baseUrl,
|
||||
ANTHROPIC_API_KEY: '', ANTHROPIC_AUTH_TOKEN: '', CLAUDE_CODE_OAUTH_TOKEN: '',
|
||||
ANTHROPIC_CUSTOM_HEADERS: '',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: '', ANTHROPIC_DEFAULT_SONNET_MODEL: '',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: '',
|
||||
},
|
||||
}),
|
||||
] : []),
|
||||
...(options.provider?.model ? ['--model', options.provider.model] : []),
|
||||
...(sessionRef ? ['--resume', sessionRef] : []),
|
||||
],
|
||||
promptArg: true,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
const NETWORK_ENV_KEYS = Object.freeze([
|
||||
'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY',
|
||||
'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy',
|
||||
'SSL_CERT_FILE', 'SSL_CERT_DIR', 'NODE_EXTRA_CA_CERTS',
|
||||
'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE', 'NODE_USE_ENV_PROXY',
|
||||
])
|
||||
|
||||
function networkEnvironment(source = {}, platform = process.platform) {
|
||||
const result = {}
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
const allowed = platform === 'win32'
|
||||
? NETWORK_ENV_KEYS.find(name => name.toLowerCase() === key.toLowerCase())
|
||||
: NETWORK_ENV_KEYS.find(name => name === key)
|
||||
if (allowed && typeof value === 'string') result[allowed] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
module.exports = { NETWORK_ENV_KEYS, networkEnvironment }
|
||||
@@ -7,7 +7,10 @@ const {
|
||||
TERMINATE_GRACE_MS,
|
||||
childEnvironment,
|
||||
} = require('./cli-process-support.cjs')
|
||||
const { configureOpenClawGatewayRuntime } = require('./openclaw-runtime.cjs')
|
||||
const {
|
||||
configureOpenClawGatewayRuntime,
|
||||
refreshOpenClawRuntimeAfterStartup,
|
||||
} = require('./openclaw-runtime.cjs')
|
||||
|
||||
const GATEWAY_READY_MARKER = '[gateway] ready'
|
||||
const GATEWAY_READY_TIMEOUT_MS = 15_000
|
||||
@@ -286,8 +289,11 @@ async function withOpenClawGateway(options, callback) {
|
||||
signal: options.signal,
|
||||
workdir: options.workdir,
|
||||
})
|
||||
runtimeOptions = refreshOpenClawRuntimeAfterStartup(runtimeOptions)
|
||||
} catch (error) {
|
||||
await terminateGateway(child)
|
||||
// Startup can migrate the config before a port conflict or health failure.
|
||||
runtimeOptions = refreshOpenClawRuntimeAfterStartup(runtimeOptions)
|
||||
if (error?.retryable && attempt + 1 < GATEWAY_SETUP_ATTEMPTS) continue
|
||||
throw attachRuntimeOptions(error, runtimeOptions)
|
||||
}
|
||||
|
||||
@@ -91,10 +91,49 @@ function appendStructuredText(state, value, separator = '\n') {
|
||||
}
|
||||
|
||||
function stripPiInternalPromptEcho(value) {
|
||||
const text = String(value || '').trim()
|
||||
let text = String(value || '').trim()
|
||||
if (!text) return ''
|
||||
const preamble = /^You are participating in a local multi-agent discussion\.[\s\S]*?^Answer the user naturally(?: in Markdown)?\.?\s*/im
|
||||
return text.replace(preamble, '').trim()
|
||||
text = text.replace(preamble, '').trim()
|
||||
// Pi may resume from a compact continuation prompt and echo only the
|
||||
// protocol instructions or the selected Harness records. Remove those
|
||||
// internal lines while preserving any user-facing text on the same line.
|
||||
text = text.replace(/^Do not output JSON, XML, receipt markers, protocol labels, hidden orchestration instructions, or a fixed response template\.\s*/im, '')
|
||||
text = text.replace(/^Do not claim another Agent performed work, and do not modify shared workspace state unless this turn explicitly grants that permission\.\s*/im, '')
|
||||
const lines = text.split(/\r?\n/)
|
||||
const cleaned = []
|
||||
let inRecords = false
|
||||
for (const line of lines) {
|
||||
const valueLine = line.trim()
|
||||
if (/^Selected records from completed phases:\s*$/i.test(valueLine)) {
|
||||
inRecords = true
|
||||
continue
|
||||
}
|
||||
if (inRecords) {
|
||||
if (/^MELDWORK_V4_COLLABORATION_PACKAGE_V1$/i.test(valueLine)
|
||||
|| /^\[index\]\s+\S+/i.test(valueLine)
|
||||
|| /^Artifact:\s+\S+/i.test(valueLine)
|
||||
|| /^Evidence:\s+\S+/i.test(valueLine)) continue
|
||||
const phasePrefix = /^(?:\[(?:challenge|proposal)\]\s+agent=\S+\s+Completed the (?:challenge|proposal) phase\.\s*)/i
|
||||
const remainder = line.replace(phasePrefix, '')
|
||||
if (remainder !== line) {
|
||||
if (remainder.trim()) cleaned.push(remainder)
|
||||
continue
|
||||
}
|
||||
inRecords = false
|
||||
}
|
||||
cleaned.push(line)
|
||||
}
|
||||
return cleaned.join('\n').replace(/\n{3,}/g, '\n\n').trim()
|
||||
}
|
||||
|
||||
function stripPiPromptEcho(value, prompt) {
|
||||
const text = String(value || '').trim()
|
||||
const expected = String(prompt || '').trim()
|
||||
if (expected && text.startsWith(expected)) {
|
||||
return text.slice(expected.length).trim()
|
||||
}
|
||||
return stripPiInternalPromptEcho(text)
|
||||
}
|
||||
|
||||
function stripExactMeldworkPromptEcho(value, prompt) {
|
||||
@@ -177,13 +216,14 @@ function ingestStructuredEvent(state, event) {
|
||||
}
|
||||
|
||||
if (state.kind === 'pi') {
|
||||
if (typeof event.id === 'string') state.sessionRef = event.id
|
||||
if (event.type === 'session' && typeof event.id === 'string') state.sessionRef = event.id
|
||||
const update = event.assistantMessageEvent
|
||||
if (event.type === 'message_update' && update?.type === 'text_delta'
|
||||
&& typeof update.delta === 'string') {
|
||||
appendStructuredText(state, update.delta, '')
|
||||
}
|
||||
if (event.type === 'message_end' && Array.isArray(event.message?.content)) {
|
||||
if (event.type === 'message_end' && event.message?.role === 'assistant'
|
||||
&& Array.isArray(event.message?.content)) {
|
||||
for (const block of event.message.content) {
|
||||
if (block?.type === 'text' && typeof block.text === 'string') {
|
||||
if (!state.text) appendStructuredText(state, block.text, '')
|
||||
@@ -195,9 +235,16 @@ function ingestStructuredEvent(state, event) {
|
||||
if (event.type === 'turn_end') {
|
||||
state.outcome = stepFinishOutcome(event.message?.stopReason || event.stopReason || 'stop')
|
||||
}
|
||||
if (event.type === 'agent_end') state.outcome = 'completed'
|
||||
if (event.type === 'agent_end') {
|
||||
const lastAssistant = event.messages?.findLast(message => message?.role === 'assistant')
|
||||
if (lastAssistant?.stopReason) state.outcome = stepFinishOutcome(lastAssistant.stopReason)
|
||||
else if (!state.outcome) state.outcome = 'completed'
|
||||
if (state.outcome === 'completed' && event.willRetry !== true) {
|
||||
state.failure = null
|
||||
state.diagnostic = ''
|
||||
}
|
||||
}
|
||||
if (state.outcome === 'failed') markStructuredFailure(state)
|
||||
state.text = stripPiInternalPromptEcho(state.text)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -320,7 +367,7 @@ function openCodeReviewTerminal(status, manifest, manifestPresent = manifest !=
|
||||
}
|
||||
}
|
||||
|
||||
function finalizeStructuredState(state) {
|
||||
function finalizeStructuredState(state, context = {}) {
|
||||
if (state.parseError) {
|
||||
return {
|
||||
text: '',
|
||||
@@ -362,7 +409,9 @@ function finalizeStructuredState(state) {
|
||||
if (!state.seen && !state.text && !state.outcome) return null
|
||||
const outcome = state.outcome || (state.text ? 'partial' : 'failed')
|
||||
const result = {
|
||||
text: state.kind === 'pi' ? stripPiInternalPromptEcho(state.text) : state.text.trim(),
|
||||
text: state.kind === 'pi'
|
||||
? stripPiPromptEcho(state.text, context.prompt)
|
||||
: state.text.trim(),
|
||||
sessionRef: state.sessionRef,
|
||||
outcome,
|
||||
}
|
||||
@@ -395,7 +444,7 @@ function createStructuredOutputAccumulator(kind, sessionRef = '') {
|
||||
return {
|
||||
format: 'jsonl',
|
||||
ingest: event => ingestStructuredEvent(state, event),
|
||||
end: () => finalizeStructuredState(state),
|
||||
end: context => finalizeStructuredState(state, context),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -669,7 +718,8 @@ function parsePiOutput(stdout) {
|
||||
&& typeof event.assistantMessageEvent.delta === 'string') {
|
||||
text += event.assistantMessageEvent.delta
|
||||
}
|
||||
if (!text && event.type === 'message_end' && Array.isArray(event.message?.content)) {
|
||||
if (!text && event.type === 'message_end' && event.message?.role === 'assistant'
|
||||
&& Array.isArray(event.message?.content)) {
|
||||
text = event.message.content
|
||||
.filter(block => block?.type === 'text' && typeof block.text === 'string')
|
||||
.map(block => block.text)
|
||||
@@ -678,7 +728,7 @@ function parsePiOutput(stdout) {
|
||||
}
|
||||
const parsedText = text.trim()
|
||||
const answer = stripPiInternalPromptEcho(parsedText)
|
||||
return { text: parsedText ? answer : String(stdout || '').trim(), sessionRef }
|
||||
return { text: parsedText ? answer : events.length ? '' : String(stdout || '').trim(), sessionRef }
|
||||
}
|
||||
|
||||
function openCodeReviewText(output) {
|
||||
@@ -793,8 +843,11 @@ function classifyCliOutcome(kind, stdout) {
|
||||
}
|
||||
if (kind === 'pi') {
|
||||
const terminal = events.findLast(event => event?.type === 'turn_end' || event?.type === 'agent_end')
|
||||
const reason = terminal?.message?.stopReason || terminal?.stopReason || 'stop'
|
||||
return { outcome: stepFinishOutcome(reason) }
|
||||
const lastAssistant = terminal?.messages?.findLast(message => message?.role === 'assistant')
|
||||
const previousTurn = events.findLast(event => event?.type === 'turn_end')
|
||||
const reason = terminal?.message?.stopReason || terminal?.stopReason || lastAssistant?.stopReason
|
||||
|| previousTurn?.message?.stopReason || previousTurn?.stopReason
|
||||
return { outcome: reason ? stepFinishOutcome(reason) : terminal ? 'completed' : 'partial' }
|
||||
}
|
||||
if (['mimo', 'opencode'].includes(kind)) {
|
||||
const finish = events.findLast(event => (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
const path = require('node:path')
|
||||
const { searchPath, systemChildEnvironment } = require('./cli-discovery.cjs')
|
||||
const { NETWORK_ENV_KEYS, networkEnvironment } = require('./cli-network-environment.cjs')
|
||||
const { redactChildSecrets } = require('./cli-runtime-events.cjs')
|
||||
const { agentRuntimeError } = require('../agent-runtime-contract.cjs')
|
||||
const { agentRuntimeError, terminalAuthenticationDiagnostic } = require('../agent-runtime-contract.cjs')
|
||||
const { validateOpenClawRuntimeGuard } = require('./openclaw-runtime.cjs')
|
||||
|
||||
const TERMINATE_GRACE_MS = 500
|
||||
@@ -21,14 +22,20 @@ const OPENCLAW_RUNTIME_PATH_KEYS = Object.freeze([
|
||||
'OPENCLAW_CONFIG_PATH',
|
||||
'OPENCLAW_WORKSPACE_DIR',
|
||||
])
|
||||
const CONTROLLED_RUNTIME_ENV = /^(?:AWS_|AZURE_|GOOGLE_|GEMINI_|ANTHROPIC_|OPENAI_|OPENROUTER_|CODEBUDDY_|HERMES_|PI_|KIMI_|MIMO_|DASHSCOPE_|CLAUDE_|OCR_|CLOUDSDK_|VERTEX_|CLOUD_ML_|MOONSHOT_|CONNECTOR_TEST_|MELDWORK_TEST_)/
|
||||
const BLOCKED_RUNTIME_ENV = /^(?:NODE_OPTIONS|NODE_PATH|LD_PRELOAD|LD_LIBRARY_PATH|DYLD_|ELECTRON_RUN_AS_NODE)$/
|
||||
|
||||
function agentExecutionError(code, diagnostic = '') {
|
||||
return agentRuntimeError(code, diagnostic)
|
||||
}
|
||||
|
||||
function authConfigurationFailure(detail) {
|
||||
return /api[ _-]?key|access[ _-]?token|refresh[ _-]?token|auth[ _-]?token|credential|auth(?:entication|orization)?|log(?:ged)?[ -]?in|sign(?:ed)?[ -]?in|unauthorized|forbidden|\b(?:401|403)\b|select an auth type|(?:provider|model).{0,80}(?:reject|configur|missing|invalid)|(?:reject|configur|missing|invalid).{0,80}(?:provider|model)|令牌|凭据|登录|认证|鉴权|(?:提供商|供应商|模型).{0,40}(?:配置|拒绝|缺失|无效)|(?:配置|拒绝|缺失|无效).{0,40}(?:提供商|供应商|模型)/i
|
||||
.test(String(detail || ''))
|
||||
return String(detail || '').split(/\r?\n/).some((line) => {
|
||||
const text = line.trim().replace(/^(?:error|fatal)\s*:\s*/i, '')
|
||||
return Boolean(terminalAuthenticationDiagnostic(text))
|
||||
|| /^(?:(?:invalid|expired|missing|incorrect)\s+(?:api[ _-]?key|(?:access|refresh|auth)[ _-]?token|credentials?)\b|(?:authentication|authorization)\s+(?:failed|required)\b|(?:please\s+)?(?:log|sign)\s+in\b|not\s+logged\s+in\b|select an auth type\b|provider rejected\b)/i.test(text)
|
||||
|| /^(?:认证|鉴权|登录)(?:失败|过期)|^(?:令牌|凭据|密钥)(?:无效|缺失|已过期)|^请(?:先|重新)登录/.test(text)
|
||||
})
|
||||
}
|
||||
|
||||
function invalidSessionFailure(detail) {
|
||||
@@ -60,7 +67,10 @@ function openClawChildEnvironment(workdir, options, platform) {
|
||||
throw new Error('OPENCLAW_RUNTIME_UNSAFE_PATH')
|
||||
}
|
||||
|
||||
const env = systemChildEnvironment(process.env, platform)
|
||||
const env = {
|
||||
...systemChildEnvironment(process.env, platform),
|
||||
...networkEnvironment(source, platform),
|
||||
}
|
||||
for (const key of [
|
||||
'HOME', 'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH',
|
||||
'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_STATE_HOME', 'XDG_CACHE_HOME',
|
||||
@@ -103,9 +113,14 @@ function childEnvironment(agent, workdir, options, platform) {
|
||||
? { OPENCODE_PERMISSION: OPENCODE_READ_ONLY_PERMISSION }
|
||||
: {}
|
||||
const source = { ...process.env, ...options.env }
|
||||
const controlled = Object.fromEntries(Object.entries(options.env || {}).filter(([key, value]) => (
|
||||
typeof value === 'string'
|
||||
&& (CONTROLLED_RUNTIME_ENV.test(key) || NETWORK_ENV_KEYS.includes(key))
|
||||
&& !BLOCKED_RUNTIME_ENV.test(key)
|
||||
)))
|
||||
return {
|
||||
...systemChildEnvironment(process.env, platform),
|
||||
...options.env,
|
||||
...controlled,
|
||||
...hermesSafetyEnv,
|
||||
...openCodeSafetyEnv,
|
||||
PATH: searchPath({ platform, env: source }),
|
||||
|
||||
@@ -28,21 +28,38 @@ function utf8ByteLength(value) {
|
||||
|
||||
function redactChildSecrets(value, env) {
|
||||
let result = String(value || '')
|
||||
for (const [name, secret] of Object.entries(env)) {
|
||||
if (!/(?:API[_-]?KEY|TOKEN|SECRET|PASSWORD|AUTHORIZATION)/i.test(name)
|
||||
|| typeof secret !== 'string' || utf8ByteLength(secret) < 8) continue
|
||||
for (const secret of childSecrets(env)) {
|
||||
result = result.split(secret).join('[redacted]')
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function childSecrets(env) {
|
||||
return Object.entries(env || {})
|
||||
.filter(([name, secret]) => (
|
||||
/(?:API[_-]?KEY|TOKEN|SECRET|PASSWORD|AUTHORIZATION)/i.test(name)
|
||||
&& typeof secret === 'string' && utf8ByteLength(secret) >= 8
|
||||
))
|
||||
.map(([, secret]) => secret)
|
||||
const secrets = new Set()
|
||||
for (const [name, value] of Object.entries(env || {})) {
|
||||
if (typeof value !== 'string' || !value) continue
|
||||
if (/(?:API[_-]?KEY|ACCESS[_-]?KEY|TOKEN|SECRET|PASSWORD|AUTHORIZATION)/i.test(name)
|
||||
&& utf8ByteLength(value) >= 8) secrets.add(value)
|
||||
if (!/^(?:https?|all)_proxy$/i.test(name)) continue
|
||||
if (value.includes('@')) secrets.add(value)
|
||||
try {
|
||||
const url = new URL(value)
|
||||
if (!url.username && !url.password) continue
|
||||
secrets.add(value)
|
||||
secrets.add(url.href)
|
||||
const userinfo = `${url.username}:${url.password}`
|
||||
secrets.add(userinfo)
|
||||
const decoded = decodeURIComponent(userinfo)
|
||||
secrets.add(decoded)
|
||||
secrets.add(Buffer.from(decoded).toString('base64'))
|
||||
for (const part of [url.username, url.password]) {
|
||||
if (utf8ByteLength(part) >= 8) secrets.add(part)
|
||||
const literal = decodeURIComponent(part)
|
||||
if (utf8ByteLength(literal) >= 8) secrets.add(literal)
|
||||
}
|
||||
} catch { /* malformed proxy URLs are diagnosed by the native runtime */ }
|
||||
}
|
||||
return [...secrets].sort((left, right) => right.length - left.length)
|
||||
}
|
||||
|
||||
function assignmentNameVariants(parts) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const crypto = require('node:crypto')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { isDeepStrictEqual } = require('node:util')
|
||||
|
||||
const { atomicWritePrivateFile } = require('../../security/private-file.cjs')
|
||||
|
||||
@@ -42,6 +43,7 @@ const OPENCLAW_RUNTIME_CREDENTIAL_KEYS = new Set([
|
||||
])
|
||||
const issuedOpenClawRuntimeGuards = new WeakSet()
|
||||
const openClawRuntimeCredentialDigests = new WeakMap()
|
||||
const openClawRuntimeConfigs = new WeakMap()
|
||||
const OPENCLAW_ENV_SECRET_NAME = /^[A-Z][A-Z0-9_]{0,127}$/
|
||||
const OPENCLAW_SECRET_MARKERS = new Set([
|
||||
'secretref-managed',
|
||||
@@ -89,7 +91,7 @@ function directoryIdentity(filename) {
|
||||
})
|
||||
}
|
||||
|
||||
function fileIdentity(filename) {
|
||||
function readRuntimeConfigFile(filename) {
|
||||
const resolved = path.resolve(filename)
|
||||
const entry = fs.lstatSync(resolved)
|
||||
if (!entry.isFile() || entry.isSymbolicLink() || fs.realpathSync(resolved) !== resolved) {
|
||||
@@ -99,14 +101,22 @@ function fileIdentity(filename) {
|
||||
const descriptor = fs.openSync(resolved, fs.constants.O_RDONLY | noFollow)
|
||||
try {
|
||||
const before = fs.fstatSync(descriptor)
|
||||
if (!before.isFile() || !sameFilesystemIdentity(before, {
|
||||
if (!before.isFile() || before.size > 256 * 1024 || !sameFilesystemIdentity(before, {
|
||||
dev: identityValue(entry.dev),
|
||||
ino: identityValue(entry.ino),
|
||||
birthtime: identityValue(entry.birthtimeMs),
|
||||
})) {
|
||||
throw new Error('OPENCLAW_RUNTIME_UNSAFE_PATH')
|
||||
}
|
||||
const contents = fs.readFileSync(descriptor)
|
||||
const buffer = Buffer.alloc(256 * 1024 + 1)
|
||||
let length = 0
|
||||
while (length < buffer.length) {
|
||||
const count = fs.readSync(descriptor, buffer, length, buffer.length - length, null)
|
||||
if (!count) break
|
||||
length += count
|
||||
}
|
||||
if (length === buffer.length) throw new Error('OPENCLAW_RUNTIME_UNSAFE_PATH')
|
||||
const contents = buffer.subarray(0, length)
|
||||
const after = fs.fstatSync(descriptor)
|
||||
const current = fs.lstatSync(resolved)
|
||||
if (!sameFilesystemIdentity(after, {
|
||||
@@ -121,7 +131,7 @@ function fileIdentity(filename) {
|
||||
}) || fs.realpathSync(resolved) !== resolved) {
|
||||
throw new Error('OPENCLAW_RUNTIME_UNSAFE_PATH')
|
||||
}
|
||||
return Object.freeze({
|
||||
const identity = Object.freeze({
|
||||
path: resolved,
|
||||
dev: identityValue(before.dev),
|
||||
ino: identityValue(before.ino),
|
||||
@@ -130,11 +140,16 @@ function fileIdentity(filename) {
|
||||
size: contents.length,
|
||||
digest: crypto.createHash('sha256').update(contents).digest('hex'),
|
||||
})
|
||||
return { identity, contents: contents.toString('utf8') }
|
||||
} finally {
|
||||
fs.closeSync(descriptor)
|
||||
}
|
||||
}
|
||||
|
||||
function fileIdentity(filename) {
|
||||
return readRuntimeConfigFile(filename).identity
|
||||
}
|
||||
|
||||
function validateDirectoryIdentities(identities) {
|
||||
for (const expected of identities) {
|
||||
const current = directoryIdentity(expected.path)
|
||||
@@ -268,6 +283,7 @@ function writeRuntimeConfig(runtime, config, credentials, credentialKey) {
|
||||
credentialKeys: Object.freeze(credentialEntries.map(([key]) => key)),
|
||||
})
|
||||
issuedOpenClawRuntimeGuards.add(guard)
|
||||
openClawRuntimeConfigs.set(guard, contents)
|
||||
openClawRuntimeCredentialDigests.set(guard, Object.freeze(Object.fromEntries(
|
||||
credentialEntries.map(([key, value]) => [
|
||||
key,
|
||||
@@ -277,7 +293,7 @@ function writeRuntimeConfig(runtime, config, credentials, credentialKey) {
|
||||
return guard
|
||||
}
|
||||
|
||||
function validateOpenClawRuntimeGuard(guard, env = {}) {
|
||||
function validateRuntimeScope(guard, env = {}) {
|
||||
const credentialDigests = guard && typeof guard === 'object'
|
||||
? openClawRuntimeCredentialDigests.get(guard)
|
||||
: undefined
|
||||
@@ -301,10 +317,57 @@ function validateOpenClawRuntimeGuard(guard, env = {}) {
|
||||
}
|
||||
}
|
||||
validateDirectoryIdentities(guard.directories)
|
||||
}
|
||||
|
||||
function validateOpenClawRuntimeGuard(guard, env = {}) {
|
||||
validateRuntimeScope(guard, env)
|
||||
validateFileIdentity(guard.config)
|
||||
return true
|
||||
}
|
||||
|
||||
function comparableMigratedConfig(config) {
|
||||
// OpenClaw materializes the implicit main agent and stamps migrations at startup.
|
||||
const result = JSON.parse(JSON.stringify(config))
|
||||
const meta = result.meta
|
||||
if (meta && typeof meta === 'object' && !Array.isArray(meta)
|
||||
&& Object.keys(meta).every(key => ['migrations', 'lastTouchedVersion', 'lastTouchedAt'].includes(key))
|
||||
&& (meta.lastTouchedVersion === undefined || /^\d{4}\.\d{1,2}\.\d{1,2}(?:[-+.][\w.-]+)?$/.test(meta.lastTouchedVersion))
|
||||
&& (meta.lastTouchedAt === undefined || (typeof meta.lastTouchedAt === 'string' && Number.isFinite(Date.parse(meta.lastTouchedAt))))
|
||||
&& (meta.migrations === undefined || isDeepStrictEqual(meta.migrations, { modelPolicyAllowlist: true }))) {
|
||||
delete result.meta
|
||||
}
|
||||
if (isDeepStrictEqual(result.agents?.entries, { main: {} })) delete result.agents.entries
|
||||
if (isDeepStrictEqual(result.discovery?.wideArea, { enabled: false })) {
|
||||
result.discovery.wideArea = {}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function refreshOpenClawRuntimeAfterStartup(options) {
|
||||
const guard = options?.openClawRuntimeGuard
|
||||
const env = options?.env || {}
|
||||
validateRuntimeScope(guard, env)
|
||||
const { identity: config, contents } = readRuntimeConfigFile(guard.config.path)
|
||||
if (process.platform !== 'win32' && (config.mode & 0o777) !== 0o600) {
|
||||
throw new Error('OPENCLAW_RUNTIME_UNSAFE_PATH')
|
||||
}
|
||||
validateDirectoryIdentities(guard.directories)
|
||||
validateFileIdentity(config)
|
||||
let equivalent = false
|
||||
try {
|
||||
equivalent = isDeepStrictEqual(
|
||||
comparableMigratedConfig(JSON.parse(openClawRuntimeConfigs.get(guard))),
|
||||
comparableMigratedConfig(JSON.parse(contents)),
|
||||
)
|
||||
} catch { /* malformed config is rejected below */ }
|
||||
if (!equivalent) throw new Error('OPENCLAW_RUNTIME_UNSAFE_PATH')
|
||||
const refreshed = Object.freeze({ ...guard, config })
|
||||
issuedOpenClawRuntimeGuards.add(refreshed)
|
||||
openClawRuntimeConfigs.set(refreshed, contents)
|
||||
openClawRuntimeCredentialDigests.set(refreshed, openClawRuntimeCredentialDigests.get(guard))
|
||||
return { ...options, openClawRuntimeGuard: refreshed }
|
||||
}
|
||||
|
||||
function configureOpenClawGatewayRuntime(options, port) {
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error('OPENCLAW_GATEWAY_PORT_INVALID')
|
||||
@@ -313,7 +376,7 @@ function configureOpenClawGatewayRuntime(options, port) {
|
||||
const env = options?.env || {}
|
||||
validateOpenClawRuntimeGuard(guard, env)
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(guard.config.path, 'utf8'))
|
||||
const config = JSON.parse(openClawRuntimeConfigs.get(guard))
|
||||
const gatewayCredentialKey = 'OPENCLAW_GATEWAY_TOKEN'
|
||||
const nextConfig = {
|
||||
...config,
|
||||
@@ -557,5 +620,6 @@ module.exports = {
|
||||
isOpenClawSecretReference,
|
||||
managedOpenClawOptions,
|
||||
nativeOpenClawOptions,
|
||||
refreshOpenClawRuntimeAfterStartup,
|
||||
validateOpenClawRuntimeGuard,
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -60,12 +60,15 @@ function publicCompatibility(agent) {
|
||||
? agent.versionIdentified
|
||||
: Boolean(resolvedVersion)
|
||||
const compatible = compatibilityState === 'compatible'
|
||||
const incompatibilityReason = compatibilityState === 'incompatible'
|
||||
const incompatibilityReason = compatibilityState === 'unknown'
|
||||
&& agent.incompatibilityReason === 'LOCAL_AGENT_CAPABILITY_PROBE_TIMEOUT'
|
||||
? 'LOCAL_AGENT_CAPABILITY_PROBE_TIMEOUT'
|
||||
: compatibilityState === 'incompatible'
|
||||
? (COMPATIBILITY_REASONS.has(agent.incompatibilityReason)
|
||||
? agent.incompatibilityReason
|
||||
: 'LOCAL_AGENT_VERSION_UNSUPPORTED')
|
||||
: ''
|
||||
const incompatibilityProbe = compatibilityState === 'incompatible'
|
||||
const incompatibilityProbe = Boolean(incompatibilityReason)
|
||||
&& /^[a-z0-9-]{1,80}$/.test(String(agent.incompatibilityProbe || ''))
|
||||
? agent.incompatibilityProbe
|
||||
: ''
|
||||
@@ -164,6 +167,9 @@ class AgentInstaller extends EventEmitter {
|
||||
agents: AGENT_CATALOG.map(profile => {
|
||||
const agent = installed.get(profile.kind)
|
||||
const recipe = installRecipe(profile.kind, this.platform)
|
||||
const targetVersion = recipe?.detectedVersion || recipe?.version || ''
|
||||
const currentVersion = String(agent?.version || '')
|
||||
const upToDate = Boolean(agent && targetVersion && currentVersion.includes(targetVersion))
|
||||
let installSupported = Boolean(recipe)
|
||||
let installErrorCode = ''
|
||||
if (!recipe) {
|
||||
@@ -177,6 +183,8 @@ class AgentInstaller extends EventEmitter {
|
||||
...profile,
|
||||
installed: Boolean(agent),
|
||||
version: agent?.version || '',
|
||||
targetVersion,
|
||||
installAction: agent ? (upToDate ? 'current' : 'update') : 'install',
|
||||
...publicCompatibility(agent),
|
||||
installSupported,
|
||||
installErrorCode,
|
||||
@@ -230,7 +238,10 @@ class AgentInstaller extends EventEmitter {
|
||||
this.invalidateDetectionCache()
|
||||
const installed = await abortable(this.detectedAgents(), signal)
|
||||
if (signal.aborted) throw abortError()
|
||||
if (installed.some(agent => agent.kind === profile.kind)) {
|
||||
const current = installed.find(agent => agent.kind === profile.kind)
|
||||
const targetVersion = recipe.detectedVersion || recipe.version
|
||||
if (current && (current.compatibilityState !== 'compatible'
|
||||
|| String(current.version || '').includes(targetVersion))) {
|
||||
throw installerError('INSTALL_AGENT_ALREADY_INSTALLED')
|
||||
}
|
||||
let command = recipe.interpreter || ''
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
const { execFile } = require('node:child_process')
|
||||
const { createHash } = require('node:crypto')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const { promisify } = require('node:util')
|
||||
const { prepareCommand, searchPath } = require('./cli/cli-adapters.cjs')
|
||||
const { isOpenClawSecretReference } = require('./cli/openclaw-runtime.cjs')
|
||||
const { NETWORK_ENV_KEYS, networkEnvironment } = require('./cli/cli-network-environment.cjs')
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
@@ -28,12 +30,13 @@ const MAX_CREDENTIAL_FILE_BYTES = 2 * 1024 * 1024
|
||||
const MAX_SHELL_ENV_BYTES = 256 * 1024
|
||||
const SHELL_ENV_CACHE_TTL_MS = 30000
|
||||
const SHELL_ENV_MARKER = '__MELDWORK_NATIVE_ENV_V1__'
|
||||
const VERTEX_MODEL_REGION_KEY = /^VERTEX_REGION_[A-Z0-9_]{1,120}$/
|
||||
const CREDENTIAL_ENV_KEYS = Object.freeze({
|
||||
codex: ['OPENAI_API_KEY'],
|
||||
hermes: ['OPENAI_API_KEY', 'OPENROUTER_API_KEY', 'ANTHROPIC_API_KEY'],
|
||||
openclaw: ['OPENAI_API_KEY', 'OPENROUTER_API_KEY'],
|
||||
workbuddy: ['CODEBUDDY_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY'],
|
||||
pi: ['PI_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY'],
|
||||
pi: ['PI_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'OPENROUTER_API_KEY', 'GEMINI_API_KEY'],
|
||||
kimi: ['MOONSHOT_API_KEY', 'KIMI_API_KEY', 'KIMI_MODEL_API_KEY', 'OPENAI_API_KEY'],
|
||||
mimo: ['MIMO_API_KEY'],
|
||||
claude: ['ANTHROPIC_API_KEY'],
|
||||
@@ -42,8 +45,13 @@ const CREDENTIAL_ENV_KEYS = Object.freeze({
|
||||
opencode: ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'OPENROUTER_API_KEY'],
|
||||
opencodereview: ['OCR_LLM_TOKEN', 'OPENAI_API_KEY'],
|
||||
})
|
||||
const GOOGLE_CLOUD_ENV_KEYS = [
|
||||
'GOOGLE_APPLICATION_CREDENTIALS', 'GOOGLE_CLOUD_PROJECT', 'GCLOUD_PROJECT',
|
||||
'GOOGLE_CLOUD_LOCATION', 'GOOGLE_CLOUD_QUOTA_PROJECT', 'CLOUDSDK_CONFIG',
|
||||
]
|
||||
const NATIVE_CONFIG_ROOT_KEYS = new Set(['CODEX_HOME', 'CLAUDE_CONFIG_DIR', 'PI_CODING_AGENT_DIR'])
|
||||
const RUNTIME_ENV_KEYS = Object.freeze({
|
||||
codex: ['OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL'],
|
||||
codex: ['OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL', 'CODEX_HOME'],
|
||||
hermes: [
|
||||
'OPENAI_API_KEY', 'OPENROUTER_API_KEY', 'ANTHROPIC_API_KEY',
|
||||
'OPENAI_BASE_URL', 'OPENAI_MODEL', 'HERMES_INFERENCE_PROVIDER', 'HERMES_INFERENCE_MODEL',
|
||||
@@ -56,6 +64,7 @@ const RUNTIME_ENV_KEYS = Object.freeze({
|
||||
pi: [
|
||||
'PI_API_KEY', 'PI_BASE_URL', 'PI_MODEL',
|
||||
'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL', 'ANTHROPIC_API_KEY',
|
||||
'OPENROUTER_API_KEY', 'GEMINI_API_KEY', 'PI_CODING_AGENT_DIR',
|
||||
],
|
||||
kimi: [
|
||||
'MOONSHOT_API_KEY', 'KIMI_API_KEY', 'KIMI_MODEL_API_KEY',
|
||||
@@ -63,9 +72,25 @@ const RUNTIME_ENV_KEYS = Object.freeze({
|
||||
'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL',
|
||||
],
|
||||
mimo: ['MIMO_API_KEY', 'MIMO_BASE_URL', 'MIMO_MODEL'],
|
||||
claude: ['ANTHROPIC_API_KEY', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_MODEL'],
|
||||
claude: [
|
||||
'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN',
|
||||
'ANTHROPIC_BASE_URL', 'ANTHROPIC_MODEL', 'CLAUDE_CONFIG_DIR',
|
||||
'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||
'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY', 'CLAUDE_CODE_USE_MANTLE',
|
||||
'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN', 'AWS_BEARER_TOKEN_BEDROCK',
|
||||
'AWS_PROFILE', 'AWS_REGION', 'AWS_DEFAULT_REGION', 'AWS_CONFIG_FILE', 'AWS_SHARED_CREDENTIALS_FILE',
|
||||
'AWS_ROLE_ARN', 'AWS_ROLE_SESSION_NAME', 'AWS_WEB_IDENTITY_TOKEN_FILE',
|
||||
'ANTHROPIC_BEDROCK_BASE_URL', 'ANTHROPIC_BEDROCK_MANTLE_BASE_URL', 'ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION',
|
||||
...GOOGLE_CLOUD_ENV_KEYS,
|
||||
'CLOUD_ML_REGION', 'ANTHROPIC_VERTEX_PROJECT_ID', 'ANTHROPIC_VERTEX_BASE_URL',
|
||||
'ANTHROPIC_FOUNDRY_RESOURCE', 'ANTHROPIC_FOUNDRY_BASE_URL',
|
||||
'ANTHROPIC_FOUNDRY_API_KEY', 'ANTHROPIC_FOUNDRY_AUTH_TOKEN',
|
||||
'AZURE_CLIENT_ID', 'AZURE_TENANT_ID', 'AZURE_CLIENT_SECRET', 'AZURE_AUTHORITY_HOST',
|
||||
'AZURE_CLIENT_CERTIFICATE_PATH', 'AZURE_CLIENT_CERTIFICATE_PASSWORD', 'AZURE_FEDERATED_TOKEN_FILE',
|
||||
'AZURE_CONFIG_DIR',
|
||||
],
|
||||
qwen: ['DASHSCOPE_API_KEY', 'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL'],
|
||||
gemini: ['GEMINI_API_KEY', 'GOOGLE_API_KEY', 'GEMINI_MODEL'],
|
||||
gemini: ['GEMINI_API_KEY', 'GOOGLE_API_KEY', 'GEMINI_MODEL', ...GOOGLE_CLOUD_ENV_KEYS],
|
||||
opencode: [
|
||||
'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'OPENROUTER_API_KEY',
|
||||
'OPENAI_BASE_URL', 'OPENAI_MODEL',
|
||||
@@ -77,6 +102,7 @@ const RUNTIME_ENV_KEYS = Object.freeze({
|
||||
})
|
||||
const SHELL_ENV_KEYS = Object.freeze([...new Set([
|
||||
'PATH',
|
||||
...NETWORK_ENV_KEYS,
|
||||
...Object.values(RUNTIME_ENV_KEYS).flat(),
|
||||
])])
|
||||
let shellEnvironmentCache = null
|
||||
@@ -94,6 +120,8 @@ function readCredentialFile(filename) {
|
||||
function containsCredential(value) {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
if (Array.isArray(value)) return value.some(containsCredential)
|
||||
if (value.type === 'api_key' && credentialLiteral(value.key)) return true
|
||||
if (value.type === 'oauth' && (credentialLiteral(value.access) || credentialLiteral(value.refresh))) return true
|
||||
return Object.entries(value).some(([key, child]) => {
|
||||
if (CREDENTIAL_FIELD.test(key) && credentialLiteral(child)) return true
|
||||
return child && typeof child === 'object' && containsCredential(child)
|
||||
@@ -131,10 +159,13 @@ function textContainsCredential(filename) {
|
||||
}
|
||||
}
|
||||
|
||||
function nativeCredentialEnvironment(kind, env = process.env) {
|
||||
function nativeCredentialEnvironment(kind, env = process.env, options = {}) {
|
||||
const result = {}
|
||||
for (const key of RUNTIME_ENV_KEYS[kind] || []) {
|
||||
if (typeof env[key] === 'string' && env[key].trim()) result[key] = env[key]
|
||||
const keys = [...(RUNTIME_ENV_KEYS[kind] || []), ...(kind === 'claude'
|
||||
? Object.keys(env).filter(key => VERTEX_MODEL_REGION_KEY.test(key)) : [])]
|
||||
for (const key of keys) {
|
||||
if (options.providerConfigured && !NATIVE_CONFIG_ROOT_KEYS.has(key)) continue
|
||||
if (typeof env[key] === 'string') result[key] = env[key]
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -147,23 +178,41 @@ function nativeCredentialKeyEnvironment(kind, env = process.env) {
|
||||
return result
|
||||
}
|
||||
|
||||
function allowedShellEnvironment(env = process.env) {
|
||||
function allowedShellEnvironment(env = process.env, platform = process.platform) {
|
||||
const source = { ...env, ...networkEnvironment(env, platform) }
|
||||
const result = {}
|
||||
for (const key of SHELL_ENV_KEYS) {
|
||||
const value = env[key]
|
||||
if (typeof value !== 'string' || !value || value.length > MAX_SHELL_ENV_BYTES) continue
|
||||
for (const key of [...SHELL_ENV_KEYS, ...Object.keys(source).filter(key => VERTEX_MODEL_REGION_KEY.test(key))]) {
|
||||
const value = source[key]
|
||||
if (typeof value !== 'string' || value.length > MAX_SHELL_ENV_BYTES) continue
|
||||
result[key] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function nativeShellCommand() {
|
||||
function nativeShellCommand(shellName) {
|
||||
const keys = SHELL_ENV_KEYS.join(' ')
|
||||
// Enumerate names only; do not dump unrelated values or depend on Electron RunAsNode.
|
||||
const regionNames = shellName === 'zsh'
|
||||
? 'builtin print -rl -- ${(k)parameters[(I)VERTEX_REGION_*]}'
|
||||
: [
|
||||
'if [ -n "${BASH_VERSION-}" ]; then',
|
||||
' builtin compgen -e VERTEX_REGION_',
|
||||
'elif [ -x /bin/bash ]; then',
|
||||
" BASH_ENV= ENV= /bin/bash --noprofile --norc -c 'builtin compgen -e VERTEX_REGION_'",
|
||||
'fi',
|
||||
].join('\n')
|
||||
return [
|
||||
`printf '${SHELL_ENV_MARKER}\\0'`,
|
||||
`for __meldwork_key in ${keys}; do`,
|
||||
`for __meldwork_key in ${keys} $(${regionNames}); do`,
|
||||
' case "$__meldwork_key" in ""|*[!A-Za-z0-9_]*) continue ;; esac',
|
||||
' case "$__meldwork_key" in VERTEX_REGION_*)',
|
||||
' __meldwork_model=${__meldwork_key#VERTEX_REGION_}',
|
||||
' case "$__meldwork_model" in ""|*[!A-Z0-9_]*) continue ;; esac',
|
||||
' [ ${#__meldwork_model} -le 120 ] || continue ;;',
|
||||
' esac',
|
||||
' eval "__meldwork_value=\\${$__meldwork_key-}"',
|
||||
' if [ -n "$__meldwork_value" ]; then',
|
||||
' eval "__meldwork_set=\\${$__meldwork_key+x}"',
|
||||
' if [ -n "$__meldwork_set" ]; then',
|
||||
" printf '%s=%s\\0' \"$__meldwork_key\" \"$__meldwork_value\"",
|
||||
' fi',
|
||||
'done',
|
||||
@@ -182,7 +231,8 @@ function parseNativeShellEnvironment(output) {
|
||||
if (separator <= 0) continue
|
||||
const key = entry.slice(0, separator)
|
||||
const value = entry.slice(separator + 1)
|
||||
if (!allowed.has(key) || !value || value.length > MAX_SHELL_ENV_BYTES) continue
|
||||
if ((!allowed.has(key) && !VERTEX_MODEL_REGION_KEY.test(key))
|
||||
|| value.length > MAX_SHELL_ENV_BYTES) continue
|
||||
result[key] = value
|
||||
}
|
||||
return result
|
||||
@@ -192,7 +242,7 @@ async function queryNativeShellEnvironment(options = {}) {
|
||||
const source = options.env || process.env
|
||||
const platform = options.platform || process.platform
|
||||
const home = options.home || os.homedir()
|
||||
const fallback = allowedShellEnvironment(source)
|
||||
const fallback = allowedShellEnvironment(source, platform)
|
||||
if (platform === 'win32') return { env: fallback, source: 'process' }
|
||||
const shell = String(options.shell || source.SHELL || (platform === 'darwin' ? '/bin/zsh' : '/bin/sh'))
|
||||
const shellName = path.basename(shell)
|
||||
@@ -207,7 +257,7 @@ async function queryNativeShellEnvironment(options = {}) {
|
||||
try {
|
||||
const result = await execFileFn(
|
||||
shell,
|
||||
[shellName === 'sh' ? '-lc' : '-lic', nativeShellCommand()],
|
||||
[shellName === 'sh' ? '-lc' : '-lic', nativeShellCommand(shellName)],
|
||||
{
|
||||
timeout: 5000,
|
||||
maxBuffer: MAX_SHELL_ENV_BYTES,
|
||||
@@ -232,9 +282,12 @@ function resolveNativeShellEnvironment(options = {}) {
|
||||
const platform = options.platform || process.platform
|
||||
const home = options.home || os.homedir()
|
||||
const shell = String(options.shell || source.SHELL || '')
|
||||
const key = [platform, home, shell, String(source.PATH || '')].join('\u0000')
|
||||
const environmentHash = createHash('sha256')
|
||||
.update(JSON.stringify(allowedShellEnvironment(source, platform))).digest('hex')
|
||||
const key = [platform, home, shell, environmentHash].join('\u0000')
|
||||
const now = Date.now()
|
||||
if (shellEnvironmentCache?.key === key && shellEnvironmentCache.expiresAt > now) {
|
||||
if (options.refresh !== true && shellEnvironmentCache?.key === key
|
||||
&& shellEnvironmentCache.expiresAt > now) {
|
||||
return shellEnvironmentCache.promise
|
||||
}
|
||||
const promise = queryNativeShellEnvironment(options)
|
||||
@@ -245,6 +298,9 @@ function resolveNativeShellEnvironment(options = {}) {
|
||||
function nativeCredentialState(kind, options = {}) {
|
||||
const home = options.home || os.homedir()
|
||||
const env = options.env || process.env
|
||||
const codexHome = env.CODEX_HOME || path.join(home, '.codex')
|
||||
const claudeHome = env.CLAUDE_CONFIG_DIR || path.join(home, '.claude')
|
||||
const piHome = env.PI_CODING_AGENT_DIR || path.join(home, '.pi', 'agent')
|
||||
if (Object.keys(nativeCredentialKeyEnvironment(kind, env)).length) {
|
||||
return {
|
||||
state: 'ready',
|
||||
@@ -253,11 +309,13 @@ function nativeCredentialState(kind, options = {}) {
|
||||
}
|
||||
|
||||
const jsonFiles = {
|
||||
codex: [path.join(home, '.codex', 'auth.json'), path.join(home, '.codex', 'config.json')],
|
||||
codex: [path.join(codexHome, 'auth.json'), path.join(codexHome, 'config.json')],
|
||||
hermes: [path.join(home, '.hermes', 'auth.json'), path.join(home, '.hermes', 'config.json')],
|
||||
openclaw: [path.join(home, '.openclaw', 'openclaw.json')],
|
||||
workbuddy: [path.join(home, '.workbuddy', 'models.json')],
|
||||
pi: [
|
||||
path.join(piHome, 'auth.json'),
|
||||
path.join(piHome, 'models.json'),
|
||||
path.join(home, '.pi', 'auth.json'),
|
||||
path.join(home, '.pi', 'config.json'),
|
||||
path.join(home, '.pi-agent', 'auth.json'),
|
||||
@@ -265,16 +323,16 @@ function nativeCredentialState(kind, options = {}) {
|
||||
],
|
||||
kimi: [path.join(home, '.kimi-code', 'credentials', 'kimi-code.json')],
|
||||
claude: [
|
||||
path.join(home, '.claude', '.credentials.json'),
|
||||
path.join(home, '.claude', 'settings.json'),
|
||||
path.join(home, '.claude', 'settings.local.json'),
|
||||
path.join(claudeHome, '.credentials.json'),
|
||||
path.join(claudeHome, 'settings.json'),
|
||||
path.join(claudeHome, 'settings.local.json'),
|
||||
],
|
||||
qwen: [path.join(home, '.qwen', 'oauth_creds.json'), path.join(home, '.qwen', 'settings.json')],
|
||||
gemini: [
|
||||
path.join(home, '.gemini', 'oauth_creds.json'),
|
||||
path.join(home, '.gemini', 'settings.json'),
|
||||
],
|
||||
opencode: [path.join(home, '.local', 'share', 'opencode', 'auth.json')],
|
||||
opencode: [path.join(env.XDG_DATA_HOME || path.join(home, '.local', 'share'), 'opencode', 'auth.json')],
|
||||
opencodereview: [path.join(home, '.opencodereview', 'config.json')],
|
||||
}[kind] || []
|
||||
if (jsonFiles.some(jsonContainsCredential)) {
|
||||
@@ -282,7 +340,7 @@ function nativeCredentialState(kind, options = {}) {
|
||||
}
|
||||
|
||||
const textFiles = {
|
||||
codex: [path.join(home, '.codex', '.env'), path.join(home, '.codex', 'config.toml')],
|
||||
codex: [path.join(codexHome, '.env'), path.join(codexHome, 'config.toml')],
|
||||
hermes: [
|
||||
path.join(home, '.hermes', '.env'),
|
||||
path.join(home, '.hermes', 'config.yaml'),
|
||||
@@ -302,7 +360,7 @@ function nativeCredentialState(kind, options = {}) {
|
||||
path.join(home, '.kimi-code', '.env'),
|
||||
],
|
||||
mimo: [path.join(home, '.mimo', '.env'), path.join(home, '.mimocode', '.env')],
|
||||
claude: [path.join(home, '.claude', '.env')],
|
||||
claude: [path.join(claudeHome, '.env')],
|
||||
qwen: [path.join(home, '.qwen', '.env')],
|
||||
gemini: [path.join(home, '.gemini', '.env')],
|
||||
opencode: [
|
||||
@@ -322,13 +380,14 @@ function probeEnvironment(kind, options = {}) {
|
||||
const source = options.env || process.env
|
||||
const platform = options.platform || process.platform
|
||||
const home = options.home || os.homedir()
|
||||
const env = {}
|
||||
const env = networkEnvironment(source, platform)
|
||||
for (const key of [
|
||||
'USER', 'LOGNAME', 'TMPDIR', 'TMP', 'TEMP',
|
||||
'LANG', 'LANGUAGE', 'LC_ALL', 'LC_CTYPE',
|
||||
'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH', 'USERNAME',
|
||||
'SYSTEMROOT', 'WINDIR', 'COMSPEC', 'PATHEXT',
|
||||
'APPDATA', 'LOCALAPPDATA',
|
||||
'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_STATE_HOME', 'XDG_CACHE_HOME',
|
||||
]) {
|
||||
if (typeof source[key] === 'string' && source[key]) env[key] = source[key]
|
||||
}
|
||||
@@ -342,6 +401,13 @@ function probeEnvironment(kind, options = {}) {
|
||||
function claudeAuthState(output) {
|
||||
try {
|
||||
const status = JSON.parse(String(output || '').trim())
|
||||
// Cloud status recognizes a provider selection without checking SDK credentials.
|
||||
if (status?.loggedIn === true && status.authMethod === 'third_party') {
|
||||
return { state: 'unknown', source: 'unverified' }
|
||||
}
|
||||
if (status?.loggedIn === true && status.authMethod === 'api_key') {
|
||||
return { state: 'ready', source: 'native-credential' }
|
||||
}
|
||||
if (status?.loggedIn === true) return { state: 'ready', source: 'native-auth-status' }
|
||||
if (status?.loggedIn === false) return { state: 'missing', source: 'native-auth-status' }
|
||||
} catch { /* keep the file-based result */ }
|
||||
@@ -666,8 +732,7 @@ async function resolveNativeCredentialState(kind, options = {}) {
|
||||
? status.credentialState
|
||||
: { state: 'unknown', source: 'native-runtime-unavailable' }
|
||||
}
|
||||
if (kind !== 'claude' || !options.executable
|
||||
|| Object.keys(nativeCredentialKeyEnvironment(kind, options.env)).length) return current
|
||||
if (kind !== 'claude' || !options.executable) return current
|
||||
|
||||
const platform = options.platform || process.platform
|
||||
const prepareCommandFn = options.prepareCommandFn || prepareCommand
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const crypto = require('node:crypto')
|
||||
const { parseTaskDecision } = require('./task-decision.cjs')
|
||||
|
||||
const { normalizeContentBlobRef } = require('../attachments/content-blob-store.cjs')
|
||||
const { canonicalJson } = require('./context-pack-records.cjs')
|
||||
@@ -9,7 +10,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 +61,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', 'discussionWriterKind', 'batchId', 'round',
|
||||
'currentKind', 'currentKinds', 'pendingKinds', 'activeKinds',
|
||||
'successfulKinds', 'agreementKinds', 'attachmentRecipients',
|
||||
'totalSuccesses', 'terminalFailureOccurred', 'collaboration', 'taskGraph',
|
||||
@@ -829,7 +830,7 @@ function normalizeDeliveryState(input, targetKinds, index) {
|
||||
if (!isRecord(input) || !exactFields(input, [
|
||||
'recipientKind', 'sessionRefHash', 'sessionProvenanceHash',
|
||||
'sourceAgentKind', 'sourcePhase', 'watermark', 'snapshotHash', 'operationId',
|
||||
'packageHash', 'deliveryId', 'status', 'updatedAt',
|
||||
'packageHash', 'deliveryId', 'status', 'updatedAt', 'sourceMessages',
|
||||
])) fail('ORCHESTRATION_V4_DELIVERY_STATE_INVALID')
|
||||
const recipientKind = cleanId(input.recipientKind)
|
||||
const sourceAgentKind = cleanId(input.sourceAgentKind)
|
||||
@@ -841,6 +842,20 @@ function normalizeDeliveryState(input, targetKinds, index) {
|
||||
const packageHash = String(input.packageHash || '')
|
||||
const deliveryId = cleanId(input.deliveryId)
|
||||
const status = String(input.status || '')
|
||||
let sourceMessages
|
||||
if (hasOwn(input, 'sourceMessages')) {
|
||||
if (!Array.isArray(input.sourceMessages) || !input.sourceMessages.length
|
||||
|| input.sourceMessages.length > 100) fail('ORCHESTRATION_V4_DELIVERY_STATE_INVALID')
|
||||
sourceMessages = input.sourceMessages.map(message => {
|
||||
if (!isRecord(message) || !exactFields(message, ['id', 'hash'])
|
||||
|| !cleanId(message.id) || cleanId(message.id) !== message.id
|
||||
|| typeof message.hash !== 'string' || !SHA256.test(message.hash)) fail('ORCHESTRATION_V4_DELIVERY_STATE_INVALID')
|
||||
return { id: message.id, hash: message.hash }
|
||||
})
|
||||
if (new Set(sourceMessages.map(message => message.id)).size !== sourceMessages.length) {
|
||||
fail('ORCHESTRATION_V4_DELIVERY_STATE_INVALID')
|
||||
}
|
||||
}
|
||||
if (!recipientKind || !sourceAgentKind || !targetKinds.includes(recipientKind)
|
||||
|| !targetKinds.includes(sourceAgentKind) || !V4_PHASES.has(sourcePhase)
|
||||
|| !Number.isSafeInteger(input.watermark) || input.watermark < 0
|
||||
@@ -861,6 +876,7 @@ function normalizeDeliveryState(input, targetKinds, index) {
|
||||
packageHash,
|
||||
deliveryId,
|
||||
status,
|
||||
...(sourceMessages ? { sourceMessages } : {}),
|
||||
updatedAt: safeTimestamp(input.updatedAt, index),
|
||||
}
|
||||
}
|
||||
@@ -1601,10 +1617,18 @@ function validateV4Relations(orchestration, targetKinds) {
|
||||
|| orchestration.synthesisBinding?.writerKind
|
||||
|| orchestration.coordinationPlan?.finalizerKind
|
||||
|| orchestration.commitState.writerKind
|
||||
|| orchestration.discussionWriterKind
|
||||
if (writableSlots.length > 1 || writableSlots.some(slot => (
|
||||
slot.agentKind !== effectiveWriterKind
|
||||
|| !['synthesis', 'human-gate'].includes(orchestration.phase)
|
||||
|| !['discussion', 'synthesis', 'human-gate'].includes(orchestration.phase)
|
||||
|| (orchestration.phase === 'discussion' && !orchestration.discussionWriterKind)
|
||||
))) fail('ORCHESTRATION_V4_PERMISSION_INVALID')
|
||||
for (const assignment of orchestration.phase === 'discussion' ? orchestration.plan.assignments : []) {
|
||||
const slot = orchestration.slots.find(item => item.slotId === assignment.slotId)
|
||||
if (slot && assignment.readOnly !== (slot.permission !== 'workspace-write')) {
|
||||
fail('ORCHESTRATION_V4_PERMISSION_INVALID')
|
||||
}
|
||||
}
|
||||
}
|
||||
const watermarkKeys = orchestration.deliveryWatermarks.map(item => `${item.agentKind}\u0000${item.phase}`)
|
||||
if (new Set(watermarkKeys).size !== watermarkKeys.length) {
|
||||
@@ -1784,6 +1808,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')
|
||||
@@ -1891,6 +1923,15 @@ function parseOrchestrationV4(input, options = {}) {
|
||||
if (coordinationPlan && (workflow !== 'auto' || template !== 'discussion')) {
|
||||
fail('ORCHESTRATION_V4_COORDINATION_PLAN_INVALID')
|
||||
}
|
||||
const discussionWriterKind = hasOwn(input, 'discussionWriterKind') ? cleanId(input.discussionWriterKind) : ''
|
||||
if (hasOwn(input, 'discussionWriterKind') && (!discussionWriterKind
|
||||
|| input.discussionWriterKind !== discussionWriterKind
|
||||
|| !effectiveKinds.includes(discussionWriterKind)
|
||||
|| workflow !== 'auto' || template !== 'discussion'
|
||||
|| !['proposal', 'discussion', 'stopped', 'failed'].includes(phase)
|
||||
|| synthesisBinding || coordinationPlan || commitState.writerKind)) {
|
||||
fail('ORCHESTRATION_V4_PERMISSION_INVALID')
|
||||
}
|
||||
if (coordinationPlan) {
|
||||
validateCoordinationAgreement(
|
||||
coordinationPlan, slots, challengeBindings, participantKinds, snapshotHash,
|
||||
@@ -2017,6 +2058,8 @@ function parseOrchestrationV4(input, options = {}) {
|
||||
workflow,
|
||||
template,
|
||||
phase,
|
||||
...(discussionStyle ? { discussionStyle } : {}),
|
||||
...(discussionWriterKind ? { discussionWriterKind } : {}),
|
||||
batchId,
|
||||
currentKinds,
|
||||
activeKinds,
|
||||
@@ -2161,7 +2204,7 @@ function parseCollaborationControlBlock(input, options = {}) {
|
||||
'claims', 'findings', 'refs', 'writer', 'writerKind', 'batchId', 'entryIds',
|
||||
'capabilities', 'intendedWork', 'deliverables', 'dependencies',
|
||||
'proposedAssignments', 'finalizerKind', 'verifierKinds', 'supportedPlanHash',
|
||||
'agreeToPlan', 'workItemId',
|
||||
'agreeToPlan', 'workItemId', 'taskDecision',
|
||||
]) || input.version !== 1) {
|
||||
fail('COLLABORATION_CONTROL_BLOCK_INVALID')
|
||||
}
|
||||
@@ -2171,6 +2214,8 @@ function parseCollaborationControlBlock(input, options = {}) {
|
||||
fail('COLLABORATION_CONTROL_BLOCK_INVALID')
|
||||
}
|
||||
const summary = safeText(input.summary, MAX_V4_SUMMARY_CHARS, true)
|
||||
const taskDecision = input.taskDecision == null ? null : parseTaskDecision(input.taskDecision)
|
||||
if (taskDecision && phase !== 'discussion') fail('COLLABORATION_CONTROL_BLOCK_INVALID')
|
||||
const boundedList = (value, limit = MAX_V4_REFS, textLimit = MAX_V4_TEXT_CHARS) => {
|
||||
if (value == null) return []
|
||||
if (!Array.isArray(value) || value.length > limit) fail('COLLABORATION_CONTROL_BLOCK_INVALID')
|
||||
@@ -2301,6 +2346,7 @@ function parseCollaborationControlBlock(input, options = {}) {
|
||||
status,
|
||||
summary,
|
||||
conclusion,
|
||||
...(taskDecision ? { taskDecision } : {}),
|
||||
artifactIds,
|
||||
evidenceIds,
|
||||
findingIds,
|
||||
@@ -2339,6 +2385,7 @@ function collaborationChars(block) {
|
||||
...(Array.isArray(block.intendedWork) ? block.intendedWork : []),
|
||||
...(Array.isArray(block.deliverables) ? block.deliverables : []),
|
||||
...(Array.isArray(block.dependencies) ? block.dependencies : []),
|
||||
...(block.taskDecision ? [block.taskDecision.reason, ...block.taskDecision.deliverables] : []),
|
||||
...(Array.isArray(block.proposedAssignments)
|
||||
? block.proposedAssignments.flatMap(item => [item.objective, item.expectedOutput])
|
||||
: []),
|
||||
@@ -2547,6 +2594,7 @@ function createCollaborationReceipt(input, options = {}) {
|
||||
...(input.supportedPlanHash ? { supportedPlanHash: input.supportedPlanHash } : {}),
|
||||
...(typeof input.agreeToPlan === 'boolean' ? { agreeToPlan: input.agreeToPlan } : {}),
|
||||
...(input.workItemId ? { workItemId: input.workItemId } : {}),
|
||||
...(input.taskDecision ? { taskDecision: input.taskDecision } : {}),
|
||||
deliveryWatermark: input.deliveryWatermark || 0,
|
||||
snapshotHash: input.snapshotHash,
|
||||
...(typeof input.writer === 'boolean' ? { writer: input.writer } : {}),
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
const { redactSecrets } = require('../security/secret-redaction.cjs')
|
||||
|
||||
function parseTaskDecision(input) {
|
||||
const invalid = () => { throw new Error('LOCAL_RUN_TASK_DECISION_INVALID') }
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)
|
||||
|| Object.keys(input).some(key => !['status', 'reason', 'deliverables', 'handoffTo', 'nextKinds'].includes(key))
|
||||
|| !['completed', 'continue', 'blocked', 'needs-human'].includes(input.status)) invalid()
|
||||
const text = (value, limit) => {
|
||||
if (typeof value !== 'string' || !value.trim() || value.length > limit
|
||||
|| /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value)) invalid()
|
||||
return redactSecrets(value.trim())
|
||||
}
|
||||
const reason = text(input.reason, 1600)
|
||||
if (!Array.isArray(input.deliverables) || input.deliverables.length > 16) invalid()
|
||||
const deliverables = input.deliverables.map(value => text(value, 800))
|
||||
if (input.status === 'completed' && !deliverables.length) invalid()
|
||||
if (input.handoffTo != null && (input.status !== 'continue'
|
||||
|| typeof input.handoffTo !== 'string'
|
||||
|| !/^[a-z0-9][a-z0-9-]{0,79}$/.test(input.handoffTo))) invalid()
|
||||
if (input.nextKinds != null && (!Array.isArray(input.nextKinds)
|
||||
|| input.nextKinds.length > 32
|
||||
|| input.nextKinds.some(kind => typeof kind !== 'string'
|
||||
|| !/^[a-z0-9][a-z0-9-]{0,79}$/i.test(kind))
|
||||
|| (input.nextKinds.length > 0 && input.status !== 'continue'))) invalid()
|
||||
const nextKinds = input.nextKinds?.map(kind => kind.toLowerCase())
|
||||
if (nextKinds && new Set(nextKinds).size !== nextKinds.length) invalid()
|
||||
return {
|
||||
status: input.status, reason, deliverables,
|
||||
...(input.handoffTo != null ? { handoffTo: input.handoffTo } : {}),
|
||||
...(nextKinds ? { nextKinds } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { parseTaskDecision }
|
||||
@@ -208,9 +208,11 @@ class HumanGateCoordinator {
|
||||
status,
|
||||
optionId: option.optionId,
|
||||
actorId: input.actorId || 'local-user',
|
||||
decidedAt: input.decidedAt || this.now(),
|
||||
decidedAt: input.decidedAt || current.decision?.decidedAt || this.now(),
|
||||
...(response ? { response } : {}),
|
||||
})
|
||||
// Recovery owns recorded decisions; repeated submissions must not dispatch them again.
|
||||
if (current.status !== 'pending') return publicHumanGate(record)
|
||||
this.clearExpiry(record.gateId)
|
||||
try {
|
||||
this.onResumed(record, record.decision)
|
||||
|
||||
@@ -25,6 +25,7 @@ const {
|
||||
shutdownAcpSessionRuntime,
|
||||
} = require('./agents/cli/cli-adapters.cjs')
|
||||
const { AgentInstaller } = require('./agents/installer/agent-installer.cjs')
|
||||
const { networkEnvironment } = require('./agents/cli/cli-network-environment.cjs')
|
||||
const { AgentConnectorInstanceStore } = require('./agents/connectors/agent-connector-instance-store.cjs')
|
||||
const { LocalAgentConnectors } = require('./agents/connectors/agent-connector-local.cjs')
|
||||
const { AgentConnectorPackageStore } = require('./agents/connectors/agent-connector-package-store.cjs')
|
||||
@@ -47,8 +48,6 @@ const {
|
||||
resolveNativeShellEnvironment,
|
||||
} = require('./agents/local-agent-readiness.cjs')
|
||||
const { LocalWorkspace } = require('./workspace/local-workspace.cjs')
|
||||
const { MediaGenerationRuntime } = require('./media/media-generation-runtime.cjs')
|
||||
const { resolveMediaProvider } = require('./media/media-provider-resolution.cjs')
|
||||
const { registerDesktopIpc } = require('./shell/main-ipc.cjs')
|
||||
const {
|
||||
isAllowedExternalUrl,
|
||||
@@ -238,9 +237,9 @@ function providerOptions(kind, context = {}) {
|
||||
)
|
||||
}
|
||||
|
||||
async function nativeShellEnvironment() {
|
||||
async function nativeShellEnvironment(options = {}) {
|
||||
const home = app.getPath('home')
|
||||
const resolved = await resolveNativeShellEnvironment({ home })
|
||||
const resolved = await resolveNativeShellEnvironment({ home, refresh: options.refresh === true })
|
||||
return {
|
||||
...resolved,
|
||||
env: { ...process.env, ...resolved.env },
|
||||
@@ -253,37 +252,6 @@ async function detectLocalAgents() {
|
||||
return detectAgents({ env: shellEnvironment.env, home: shellEnvironment.home })
|
||||
}
|
||||
|
||||
function mediaFallbackProviders(env = process.env) {
|
||||
const apiKey = String(
|
||||
env.ZGCI_MEDIA_API_KEY
|
||||
|| env.ZGCI_API_KEY
|
||||
|| env.ZCGI_API_KEY
|
||||
|| env.ZGCI_LLM_API_KEY
|
||||
|| '',
|
||||
).trim()
|
||||
if (!apiKey) return []
|
||||
const baseUrl = String(env.ZGCI_MEDIA_BASE_URL || 'https://hub.zgci.org/v1')
|
||||
.trim()
|
||||
.replace(/\/+$/, '')
|
||||
.replace(/\/chat\/completions$/i, '')
|
||||
let parsed
|
||||
try { parsed = new URL(baseUrl) } catch { return [] }
|
||||
if (parsed.protocol !== 'https:' || parsed.username || parsed.password
|
||||
|| parsed.search || parsed.hash) {
|
||||
return []
|
||||
}
|
||||
return [{
|
||||
kind: 'zgci-media',
|
||||
status: {
|
||||
configured: true,
|
||||
provider: 'ZGCI Media',
|
||||
baseUrl,
|
||||
model: 'glm',
|
||||
},
|
||||
credentials: { OPENAI_API_KEY: apiKey },
|
||||
}]
|
||||
}
|
||||
|
||||
function workspaceStoragePath(userData = app.getPath('userData')) {
|
||||
return path.join(userData, 'meldwork-workspace.json')
|
||||
}
|
||||
@@ -374,6 +342,9 @@ async function runCoreAgent(agent, prompt, workdir, options = {}) {
|
||||
const runOptions = { ...options }
|
||||
delete runOptions.connectorCredentialIsolation
|
||||
const status = providerStore.status(agent.kind)
|
||||
if (!connectorCredentialIsolation && status.error) {
|
||||
throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE')
|
||||
}
|
||||
const shellEnvironment = connectorCredentialIsolation ? null : await nativeShellEnvironment()
|
||||
const nativeRuntime = !connectorCredentialIsolation
|
||||
&& agent.kind === 'openclaw' && !status.configured
|
||||
@@ -394,7 +365,9 @@ async function runCoreAgent(agent, prompt, workdir, options = {}) {
|
||||
const nativeEnv = connectorCredentialIsolation || agent.kind === 'openclaw'
|
||||
? {}
|
||||
: {
|
||||
...nativeCredentialEnvironment(agent.kind, shellEnvironment.env),
|
||||
...nativeCredentialEnvironment(agent.kind, shellEnvironment.env, {
|
||||
providerConfigured: status.configured,
|
||||
}),
|
||||
...(shellEnvironment.env.PATH ? { PATH: shellEnvironment.env.PATH } : {}),
|
||||
}
|
||||
const callerEnv = connectorCredentialIsolation || agent.kind !== 'openclaw'
|
||||
@@ -403,7 +376,10 @@ async function runCoreAgent(agent, prompt, workdir, options = {}) {
|
||||
return runAgent(agent, prompt, workdir, {
|
||||
...runOptions,
|
||||
...injected,
|
||||
env: { ...nativeEnv, ...callerEnv, ...injected.env },
|
||||
env: {
|
||||
...networkEnvironment(shellEnvironment?.env),
|
||||
...nativeEnv, ...callerEnv, ...injected.env,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -533,17 +509,6 @@ function createWorkspace() {
|
||||
}),
|
||||
connectors: [],
|
||||
})
|
||||
const mediaGeneration = new MediaGenerationRuntime({
|
||||
getProvider: (kind, type, excludedKinds) => resolveMediaProvider({
|
||||
requestedKind: kind,
|
||||
type,
|
||||
kinds: EXTERNAL_PROVIDER_KINDS,
|
||||
excludedKinds,
|
||||
statusFor: candidateKind => providerStore.status(candidateKind),
|
||||
credentialsFor: candidateKind => providerStore.envForAgent(candidateKind),
|
||||
fallbackProviders: mediaFallbackProviders(),
|
||||
}),
|
||||
})
|
||||
const localWorkspace = new LocalWorkspace({
|
||||
storagePath: workspaceStoragePath(),
|
||||
contentBlobStore,
|
||||
@@ -577,6 +542,9 @@ function createWorkspace() {
|
||||
credentialState: async (kind, agent) => {
|
||||
if (customAgentStore.has(kind)) return { state: 'ready', source: 'custom-agent' }
|
||||
if (agentConnectors.has(kind)) return { state: 'ready', source: 'agent-connector' }
|
||||
if (providerStore.status(kind).error) {
|
||||
return { state: 'unknown', source: 'provider-credential-unavailable' }
|
||||
}
|
||||
const shellEnvironment = await nativeShellEnvironment()
|
||||
return resolveNativeCredentialState(kind, {
|
||||
executable: agent?.executable,
|
||||
@@ -589,7 +557,6 @@ function createWorkspace() {
|
||||
sharedProviderReady: kind => EXTERNAL_PROVIDER_KINDS.has(kind) && providerStore.status(kind).configured,
|
||||
connectorRuntime: agentConnectors,
|
||||
naturalAgentResponses: true,
|
||||
generateMedia: input => mediaGeneration.generate(input),
|
||||
runAgent: async (agent, prompt, workdir, options = {}) => {
|
||||
if (customAgentStore.has(agent.kind)) {
|
||||
return customAgentStore.run(agent.kind, prompt, workdir, {
|
||||
@@ -666,6 +633,7 @@ async function refreshLocalAgentState() {
|
||||
if (!target) return null
|
||||
const task = localAgentRefreshQueue.then(async () => {
|
||||
if (workspace !== target) return workspace?.snapshot() || null
|
||||
await nativeShellEnvironment({ refresh: true })
|
||||
const snapshot = await target.refreshAgents()
|
||||
if (workspace !== target) return workspace?.snapshot() || null
|
||||
return snapshot
|
||||
|
||||
@@ -102,9 +102,11 @@ function providerOptionsFor(kind, generic, context = {}, status = {}) {
|
||||
}
|
||||
if (kind === 'claude') {
|
||||
return {
|
||||
provider: { id: 'anthropic', model: generic.OPENAI_MODEL, baseUrl: generic.OPENAI_BASE_URL },
|
||||
env: {
|
||||
...generic,
|
||||
ANTHROPIC_API_KEY: generic.OPENAI_API_KEY,
|
||||
MELDWORK_PROVIDER_API_KEY: generic.OPENAI_API_KEY,
|
||||
ANTHROPIC_BASE_URL: generic.OPENAI_BASE_URL,
|
||||
ANTHROPIC_MODEL: generic.OPENAI_MODEL,
|
||||
},
|
||||
|
||||
@@ -116,31 +116,34 @@ class ProviderStore {
|
||||
const encryptionAvailable = this.encryptionAvailable()
|
||||
let metadata = EMPTY_METADATA
|
||||
let configured = false
|
||||
let credentialUnavailable = false
|
||||
let activePreset = 'official'
|
||||
const profiles = {}
|
||||
if (encryptionAvailable && credentialStored) {
|
||||
if (credentialStored) {
|
||||
try {
|
||||
const agent = this.readDocument().agents[targetKind]
|
||||
if (agent) {
|
||||
activePreset = agent.activePreset
|
||||
for (const [preset, entry] of Object.entries(agent.profiles)) {
|
||||
profiles[preset] = {
|
||||
provider: entry.provider,
|
||||
baseUrl: entry.baseUrl,
|
||||
model: entry.model,
|
||||
configured: false,
|
||||
}
|
||||
try {
|
||||
this.decryptEntry(entry)
|
||||
profiles[preset] = {
|
||||
provider: entry.provider,
|
||||
baseUrl: entry.baseUrl,
|
||||
model: entry.model,
|
||||
configured: true,
|
||||
}
|
||||
} catch { /* keep an unreadable profile unavailable */ }
|
||||
profiles[preset].configured = true
|
||||
} catch { /* retain saved metadata while the credential is unavailable */ }
|
||||
}
|
||||
const activeEntry = profiles[activePreset]
|
||||
if (activeEntry) {
|
||||
metadata = activeEntry
|
||||
configured = true
|
||||
configured = activeEntry.configured
|
||||
credentialUnavailable = !configured
|
||||
}
|
||||
}
|
||||
} catch { /* unreadable credentials stay unavailable */ }
|
||||
} catch { credentialUnavailable = true }
|
||||
}
|
||||
return {
|
||||
provider: metadata.provider,
|
||||
@@ -150,6 +153,7 @@ class ProviderStore {
|
||||
profiles,
|
||||
encryptionAvailable,
|
||||
configured,
|
||||
...(credentialUnavailable ? { error: true } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -122,6 +122,8 @@ function normalizeSessionMeta(input) {
|
||||
meta.inheritedTaskIds = inheritedTaskIds
|
||||
meta.provenanceCompleteness = provenanceCompleteness
|
||||
}
|
||||
const lastDeliveredMessageId = cleanId(input.lastDeliveredMessageId)
|
||||
if (lastDeliveredMessageId) meta.lastDeliveredMessageId = lastDeliveredMessageId
|
||||
return meta
|
||||
}
|
||||
|
||||
@@ -157,6 +159,8 @@ function nextSessionMeta(meta, usage = {}) {
|
||||
if (Object.hasOwn(usage, field)) next[field] = usage[field]
|
||||
else if (Object.hasOwn(previous, field)) next[field] = previous[field]
|
||||
}
|
||||
if (usage.lastDeliveredMessageId) next.lastDeliveredMessageId = usage.lastDeliveredMessageId
|
||||
else if (previous.lastDeliveredMessageId) next.lastDeliveredMessageId = previous.lastDeliveredMessageId
|
||||
return normalizeSessionMeta(next)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const MAX_CAPSULE_EVENTS = 12
|
||||
const { parseTaskDecision } = require('../collaboration/task-decision.cjs')
|
||||
|
||||
const { redactSecrets } = require('../security/secret-redaction.cjs')
|
||||
const { normalizeContentBlobRef } = require('../attachments/content-blob-store.cjs')
|
||||
@@ -252,6 +253,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
|
||||
@@ -268,6 +273,10 @@ function normalizeContextStats(input) {
|
||||
if (externalRunRef) context.externalRunRef = externalRunRef
|
||||
const outcomeRefs = normalizeOutcomeRefs(input.outcomeRefs)
|
||||
if (Object.keys(outcomeRefs).length) context.outcomeRefs = outcomeRefs
|
||||
if (input.taskDecision != null) {
|
||||
try { context.taskDecision = parseTaskDecision(input.taskDecision) }
|
||||
catch { /* malformed task judgments cannot authorize completion */ }
|
||||
}
|
||||
const connectorContext = normalizeConnectorContext(input)
|
||||
if (connectorContext) Object.assign(context, connectorContext)
|
||||
return context
|
||||
|
||||
@@ -28,8 +28,8 @@ const CONTEXT_FIELDS = new Set([
|
||||
'includedCount', 'omittedCount', 'charCount', 'sessionRotated', 'externalRunRef',
|
||||
'contextMode', 'promptChars', 'contextPackId', 'contextPackState', 'deliveryRecordIds',
|
||||
'sessionProvenance', 'outcomeRefs', 'sourceCount', 'sourceHash', 'promptBytes',
|
||||
'promptHash', 'wirePayloadBytes', 'wirePayloadHash',
|
||||
'connector', 'connectorEventState',
|
||||
'promptHash', 'wirePayloadBytes', 'wirePayloadHash', 'operationId', 'snapshotHash',
|
||||
'connector', 'connectorEventState', 'taskDecision',
|
||||
])
|
||||
|
||||
function clone(value) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const { redactSecrets } = require('../security/secret-redaction.cjs')
|
||||
const { parseTaskDecision } = require('../collaboration/task-decision.cjs')
|
||||
const { normalizeExternalRunRef } = require('../agents/agent-runtime-contract.cjs')
|
||||
const { parseConnectorRunSnapshot } = require('../agents/connectors/agent-connector-registry.cjs')
|
||||
const { parseRunEventState } = require('./run-event-protocol.cjs')
|
||||
@@ -42,8 +43,8 @@ const CONTEXT_FIELDS = new Set([
|
||||
'includedCount', 'omittedCount', 'charCount', 'sessionRotated', 'externalRunRef',
|
||||
'contextMode', 'promptChars', 'contextPackId', 'contextPackState', 'deliveryRecordIds',
|
||||
'sessionProvenance', 'outcomeRefs', 'sourceCount', 'sourceHash', 'promptBytes',
|
||||
'promptHash', 'wirePayloadBytes', 'wirePayloadHash',
|
||||
'connector', 'connectorEventState',
|
||||
'promptHash', 'wirePayloadBytes', 'wirePayloadHash', 'operationId', 'snapshotHash',
|
||||
'connector', 'connectorEventState', 'taskDecision',
|
||||
])
|
||||
const CONTEXT_PACK_STATES = new Set(['captured', 'legacy-unavailable'])
|
||||
const REQUIRED_BUDGET_FIELDS = new Set(['limits', 'used', 'source', 'enforcement', 'startedAt'])
|
||||
@@ -62,11 +63,11 @@ 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([
|
||||
'agent_slot', 'role_review_decision', 'v4_human_gate', 'v4_synthesis_recovery',
|
||||
'agent_slot', 'role_review_decision', 'v4_human_gate', 'v4_synthesis_recovery', 'v4_task_decision',
|
||||
])
|
||||
const CONTINUATION_STATES = new Set([
|
||||
'pending', 'ready', 'resuming', 'completed', 'failed', 'cancelled',
|
||||
@@ -75,8 +76,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', 'discussionWriterKind', 'batchId', 'round', 'currentKinds', 'snapshotHash', 'snapshot', 'plan',
|
||||
'slots', 'deliveryWatermarks', 'deliveryState', 'commitState', 'challengeBindings', 'synthesisBinding',
|
||||
'synthesisRecovery', 'convergence', 'coordinationPlan', 'workReceipts', 'candidateCommit',
|
||||
])
|
||||
@@ -239,7 +241,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', 'taskDecision',
|
||||
].some(key => hasOwn(input.context, key))
|
||||
const rawEvents = Array.isArray(input.events) ? input.events : []
|
||||
const rawSourceIds = Array.isArray(input.sourceMessageIds) ? input.sourceMessageIds : []
|
||||
@@ -403,16 +405,18 @@ function normalizeContinuation(input) {
|
||||
|| !CONTINUATION_STATES.has(state) || !agentRunId || !agentKind
|
||||
|| (hasPublicAgentRunId && !publicAgentRunId)
|
||||
|| (resumeKind === 'role_review_decision' && gateType !== 'decision')
|
||||
|| (resumeKind === 'v4_task_decision'
|
||||
&& (gateType !== 'input' || !hasInvocationBinding || phase !== 'discussion'))
|
||||
|| (resumeKind === 'v4_human_gate'
|
||||
&& (gateType !== 'decision' || !hasStateEpoch || stateEpoch < 1))
|
||||
|| (resumeKind === 'v4_synthesis_recovery'
|
||||
&& (gateType !== 'decision' || !hasStateEpoch))
|
||||
|| (!['v4_human_gate', 'v4_synthesis_recovery'].includes(resumeKind) && hasStateEpoch)
|
||||
|| (invocationFieldCount > 0 && !hasInvocationBinding)
|
||||
|| (hasInvocationBinding && (resumeKind !== 'agent_slot'
|
||||
|| (hasInvocationBinding && (!['agent_slot', 'v4_task_decision'].includes(resumeKind)
|
||||
|| !CONTINUATION_V4_PHASES.has(phase) || !slotId || !operationId
|
||||
|| !SHA256.test(snapshotHash)))
|
||||
|| (gateType === 'input' && !hasRequestBinding)
|
||||
|| (gateType === 'input' && resumeKind !== 'v4_task_decision' && !hasRequestBinding)
|
||||
|| (!hasRequestBinding && [requestId, requestHash, sessionRefHash, sessionProvenanceHash]
|
||||
.some(Boolean))) return undefined
|
||||
return {
|
||||
@@ -441,7 +445,8 @@ function continuationBindingMatchesOrchestration(continuation, orchestration) {
|
||||
}
|
||||
const hasBinding = CONTINUATION_INVOCATION_FIELDS.every(field => hasOwn(continuation || {}, field))
|
||||
if (!hasBinding) return true
|
||||
if (continuation.resumeKind !== 'agent_slot' || orchestration?.version !== 4) return false
|
||||
if (!['agent_slot', 'v4_task_decision'].includes(continuation.resumeKind)
|
||||
|| orchestration?.version !== 4) return false
|
||||
if (!['pending', 'ready', 'resuming'].includes(continuation.state)) return true
|
||||
const slots = orchestration.slots.filter(slot => slot.slotId === continuation.slotId)
|
||||
const orchestrationRound = orchestration.workflow === 'manual'
|
||||
@@ -489,8 +494,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', 'discussionWriterKind', 'batchId', 'round', 'currentKinds', 'snapshotHash', 'snapshot', 'plan',
|
||||
'slots', 'deliveryWatermarks', 'deliveryState', 'commitState', 'challengeBindings',
|
||||
'synthesisBinding', 'synthesisRecovery', 'convergence', 'candidateCommit',
|
||||
]
|
||||
@@ -498,6 +504,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 +539,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 +705,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
|
||||
@@ -721,6 +737,12 @@ function hasValidStoredRecordShape(input) {
|
||||
|| canonicalJson(normalizedProvenance)
|
||||
!== canonicalJson(agentRun.context.sessionProvenance)) return false
|
||||
}
|
||||
if (hasOwn(agentRun.context, 'taskDecision')) {
|
||||
try {
|
||||
if (canonicalJson(parseTaskDecision(agentRun.context.taskDecision))
|
||||
!== canonicalJson(agentRun.context.taskDecision)) return false
|
||||
} catch { return false }
|
||||
}
|
||||
if (hasOwn(agentRun.context, 'outcomeRefs')) {
|
||||
if (!isRecord(agentRun.context.outcomeRefs)) return false
|
||||
const normalizedOutcomeRefs = normalizeOutcomeRefs(agentRun.context.outcomeRefs)
|
||||
|
||||
@@ -58,6 +58,7 @@ class RunScheduler {
|
||||
this.activeGlobal = 0
|
||||
this.activeTasks = new Map()
|
||||
this.activeWorkspaces = new Map()
|
||||
this.activeWorkspaceWriters = new Set()
|
||||
this.queue = []
|
||||
this.sequence = 0
|
||||
schedulerPauseTokens.set(this, new Map())
|
||||
@@ -69,6 +70,8 @@ class RunScheduler {
|
||||
|
||||
canGrant(entry) {
|
||||
return taskPauseAllows(this, entry)
|
||||
&& (entry.permissionMode !== 'workspace-write'
|
||||
|| !this.activeWorkspaceWriters.has(entry.workspaceKey))
|
||||
&& belowLimit(this.activeGlobal, this.limits.global)
|
||||
&& belowLimit(this.resourceCount(this.activeTasks, entry.taskId), this.limits.task)
|
||||
&& belowLimit(this.resourceCount(this.activeWorkspaces, entry.workspaceKey), this.limits.workspace)
|
||||
@@ -84,12 +87,14 @@ class RunScheduler {
|
||||
this.activeGlobal += 1
|
||||
this.changeCount(this.activeTasks, entry.taskId, 1)
|
||||
this.changeCount(this.activeWorkspaces, entry.workspaceKey, 1)
|
||||
if (entry.permissionMode === 'workspace-write') this.activeWorkspaceWriters.add(entry.workspaceKey)
|
||||
entry.signal?.removeEventListener('abort', entry.abortHandler)
|
||||
let released = false
|
||||
const lease = Object.freeze({
|
||||
leaseId: entry.requestId,
|
||||
taskId: entry.taskId,
|
||||
workspaceKey: entry.workspaceKey,
|
||||
permissionMode: entry.permissionMode,
|
||||
queuedAt: entry.queuedAt,
|
||||
acquiredAt: this.now(),
|
||||
release: () => {
|
||||
@@ -98,6 +103,7 @@ class RunScheduler {
|
||||
this.activeGlobal -= 1
|
||||
this.changeCount(this.activeTasks, entry.taskId, -1)
|
||||
this.changeCount(this.activeWorkspaces, entry.workspaceKey, -1)
|
||||
if (entry.permissionMode === 'workspace-write') this.activeWorkspaceWriters.delete(entry.workspaceKey)
|
||||
this.drain()
|
||||
return true
|
||||
},
|
||||
@@ -105,6 +111,7 @@ class RunScheduler {
|
||||
schedulerLeaseBindings.set(lease, Object.freeze({
|
||||
taskId: entry.taskId,
|
||||
workspaceKey: entry.workspaceKey,
|
||||
permissionMode: entry.permissionMode,
|
||||
signal: entry.signal,
|
||||
}))
|
||||
entry.resolve(lease)
|
||||
@@ -129,9 +136,13 @@ class RunScheduler {
|
||||
#enqueueAcquire(input = {}, pauseToken = null) {
|
||||
let taskId
|
||||
let workspaceKey
|
||||
const permissionMode = input.permissionMode == null ? 'read-only' : input.permissionMode
|
||||
try {
|
||||
taskId = resourceKey(input.taskId, 'RUN_SCHEDULER_TASK_REQUIRED')
|
||||
workspaceKey = resourceKey(input.workspaceKey, 'RUN_SCHEDULER_WORKSPACE_REQUIRED')
|
||||
if (!['read-only', 'workspace-write'].includes(permissionMode)) {
|
||||
throw schedulerError('RUN_SCHEDULER_PERMISSION_INVALID')
|
||||
}
|
||||
} catch (error) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
@@ -142,6 +153,7 @@ class RunScheduler {
|
||||
requestId: `lease-${++this.sequence}`,
|
||||
taskId,
|
||||
workspaceKey,
|
||||
permissionMode,
|
||||
queuedAt: this.now(),
|
||||
signal,
|
||||
resolve,
|
||||
@@ -176,6 +188,7 @@ class RunScheduler {
|
||||
get leaseId() { return activeLease?.leaseId || '' },
|
||||
get taskId() { return activeLease?.taskId || '' },
|
||||
get workspaceKey() { return activeLease?.workspaceKey || '' },
|
||||
get permissionMode() { return activeLease?.permissionMode || '' },
|
||||
get queuedAt() { return activeLease?.queuedAt || 0 },
|
||||
get acquiredAt() { return activeLease?.acquiredAt || 0 },
|
||||
release: () => {
|
||||
|
||||
@@ -3,6 +3,15 @@ const {
|
||||
} = require('../agents/agent-runtime-contract.cjs')
|
||||
|
||||
const RECENT_VERIFICATION_MS = 24 * 60 * 60 * 1000
|
||||
const AUTH_FAILURE_RETRY_MS = 30 * 1000
|
||||
|
||||
function expiredAuthFailure(runtime, now) {
|
||||
if (runtime?.credentialState !== 'missing') return false
|
||||
const checkedAt = Date.parse(String(runtime.checkedAt || ''))
|
||||
const current = Date.parse(String(now || ''))
|
||||
return !Number.isFinite(checkedAt) || (Number.isFinite(current)
|
||||
&& (current < checkedAt || current - checkedAt >= AUTH_FAILURE_RETRY_MS))
|
||||
}
|
||||
|
||||
function agentVersionIdentified(agent) {
|
||||
if (agent?.custom === true) return true
|
||||
@@ -67,16 +76,21 @@ class LocalWorkspaceAgentCatalog {
|
||||
async refreshOnce() {
|
||||
const runtimeAtRefreshStart = new Map(Object.entries(this.state().agentRuntime))
|
||||
const detected = await this.detectAgents()
|
||||
const nativeStates = await Promise.all(detected.map((agent) => {
|
||||
const nativeStates = await Promise.all(detected.map(async (agent) => {
|
||||
const runtime = runtimeAtRefreshStart.get(agent.kind)
|
||||
const sharedProviderReady = Boolean(this.sharedProviderReady(agent.kind))
|
||||
if (sharedProviderReady && runtime?.credentialState === 'missing') {
|
||||
if (sharedProviderReady && runtime?.credentialState === 'missing'
|
||||
&& !expiredAuthFailure(runtime, this.now())) {
|
||||
return { state: 'missing', source: 'runtime-auth-failure' }
|
||||
}
|
||||
if (sharedProviderReady) {
|
||||
return { state: 'ready', source: 'shared-provider' }
|
||||
}
|
||||
return this.credentialState(agent.kind, agent)
|
||||
try {
|
||||
return await this.credentialState(agent.kind, agent)
|
||||
} catch {
|
||||
return { state: 'unknown', source: 'native-probe-failed' }
|
||||
}
|
||||
}))
|
||||
const state = this.state()
|
||||
let recoveredRuntimeCredential = false
|
||||
@@ -87,6 +101,15 @@ class LocalWorkspaceAgentCatalog {
|
||||
const nativeState = ['ready', 'missing'].includes(native?.state) ? native.state : 'unknown'
|
||||
const authoritativeNativeState = native?.source === 'native-auth-status'
|
||||
const sharedProviderRequired = native?.source === 'shared-provider-required'
|
||||
const providerCredentialUnavailable = native?.source === 'provider-credential-unavailable'
|
||||
// Give corrected native credentials a retry after the cooldown, never erase a newer failure.
|
||||
if (expiredAuthFailure(runtime, this.now())
|
||||
&& !(authoritativeNativeState && nativeState === 'ready')
|
||||
&& runtime === runtimeAtRefreshStart.get(agent.kind)) {
|
||||
delete state.agentRuntime[agent.kind]
|
||||
runtime = undefined
|
||||
recoveredRuntimeCredential = true
|
||||
}
|
||||
// A probe that started before a newer runtime failure cannot clear that failure.
|
||||
if (authoritativeNativeState && nativeState === 'ready'
|
||||
&& ['missing', 'unknown'].includes(runtime?.credentialState)
|
||||
@@ -113,27 +136,32 @@ class LocalWorkspaceAgentCatalog {
|
||||
if (!runtimeMissing && sharedProviderReady) credentialState = 'ready'
|
||||
else if (!runtimeMissing && nativeState === 'ready') credentialState = 'ready'
|
||||
else if (!runtimeMissing && verifiedReady && nativeState !== 'missing') credentialState = 'ready'
|
||||
if (providerCredentialUnavailable) credentialState = 'unknown'
|
||||
const installed = true
|
||||
const versionIdentified = agentVersionIdentified(agent)
|
||||
const compatible = agentCompatible(agent)
|
||||
const nativeConfigurationUnknown = !runtimeMissing
|
||||
&& !providerCredentialUnavailable
|
||||
&& nativeState === 'unknown'
|
||||
&& native?.source !== 'native-runtime-unavailable'
|
||||
const configured = sharedProviderReady
|
||||
&& native?.source !== 'native-probe-failed'
|
||||
const configured = providerCredentialUnavailable || sharedProviderReady
|
||||
|| nativeState === 'ready'
|
||||
|| native?.source === 'native-auth-status'
|
||||
|| native?.source === 'native-runtime-unavailable'
|
||||
|| verifiedReady
|
||||
|| runtimeMissing
|
||||
|| nativeConfigurationUnknown
|
||||
const authenticated = !runtimeMissing && nativeState !== 'missing' && (
|
||||
const authenticated = !providerCredentialUnavailable && !runtimeMissing && nativeState !== 'missing' && (
|
||||
sharedProviderReady || nativeState === 'ready' || verifiedReady || nativeConfigurationUnknown
|
||||
)
|
||||
const runtimePrerequisitesReady = native?.source !== 'native-runtime-unavailable'
|
||||
&& (native?.source !== 'native-probe-failed' || recentlyVerified(runtime, this.now()))
|
||||
const invocable = installed && versionIdentified && compatible && configured
|
||||
&& authenticated && runtimePrerequisitesReady
|
||||
const verifiedRecently = (authoritativeNativeState && nativeState === 'ready')
|
||||
|| recentlyVerified(runtime, this.now())
|
||||
const verifiedRecently = !providerCredentialUnavailable && (
|
||||
(authoritativeNativeState && nativeState === 'ready') || recentlyVerified(runtime, this.now())
|
||||
)
|
||||
const available = invocable
|
||||
const preferred = state.agentPreferences[agent.kind]?.showInSidebar
|
||||
const capabilities = agentRuntimeCapabilities(agent.kind, {
|
||||
@@ -142,9 +170,11 @@ class LocalWorkspaceAgentCatalog {
|
||||
})
|
||||
let availabilitySource = 'unverified'
|
||||
if (!compatible) availabilitySource = 'incompatible'
|
||||
else if (providerCredentialUnavailable) availabilitySource = 'provider-credential-unavailable'
|
||||
else if (runtimeMissing) availabilitySource = 'runtime-auth-failure'
|
||||
else if (sharedProviderReady) availabilitySource = nativeReadySource || 'shared-provider'
|
||||
else if (nativeState === 'missing') availabilitySource = native.source || 'none'
|
||||
else if (native?.source === 'native-probe-failed') availabilitySource = 'native-probe-failed'
|
||||
else if (nativeReadySource) availabilitySource = nativeReadySource
|
||||
else if (verifiedReady) availabilitySource = 'verified-run'
|
||||
else if (nativeConfigurationUnknown) availabilitySource = 'local-cli'
|
||||
@@ -163,8 +193,7 @@ class LocalWorkspaceAgentCatalog {
|
||||
task: capabilities.task,
|
||||
resumable: capabilities.resumable,
|
||||
capabilities,
|
||||
showInSidebar: available
|
||||
&& (typeof preferred === 'boolean' ? preferred : true),
|
||||
showInSidebar: typeof preferred === 'boolean' ? preferred : true,
|
||||
}
|
||||
})
|
||||
this.setDetectedAgents(agents)
|
||||
@@ -176,10 +205,9 @@ class LocalWorkspaceAgentCatalog {
|
||||
setSidebarVisibility(kind, visible) {
|
||||
const agent = this.detectedAgents().find(item => item.kind === kind)
|
||||
if (!agent) throw new Error('LOCAL_AGENT_NOT_INSTALLED')
|
||||
if (visible && !agent.available) throw new Error('LOCAL_AGENT_UNAVAILABLE')
|
||||
const state = this.state()
|
||||
state.agentPreferences[kind] = { showInSidebar: Boolean(visible) }
|
||||
agent.showInSidebar = agent.available && Boolean(visible)
|
||||
agent.showInSidebar = Boolean(visible)
|
||||
this.save()
|
||||
this.emitChanged()
|
||||
return this.snapshot()
|
||||
@@ -192,7 +220,8 @@ class LocalWorkspaceAgentCatalog {
|
||||
checkedAt: this.now(),
|
||||
}
|
||||
const agent = this.detectedAgents().find(item => item.kind === kind)
|
||||
if (agent) {
|
||||
if (agent && agent.availabilitySource !== 'provider-credential-unavailable'
|
||||
&& !(credentialState === 'unknown' && agent.authenticated === true)) {
|
||||
agent.credentialState = credentialState
|
||||
agent.versionIdentified = agentVersionIdentified(agent)
|
||||
agent.compatible = agentCompatible(agent)
|
||||
@@ -210,8 +239,7 @@ class LocalWorkspaceAgentCatalog {
|
||||
? 'runtime-auth-failure'
|
||||
: 'unverified'
|
||||
const preferred = state.agentPreferences[kind]?.showInSidebar
|
||||
agent.showInSidebar = agent.available
|
||||
&& (typeof preferred === 'boolean' ? preferred : true)
|
||||
agent.showInSidebar = typeof preferred === 'boolean' ? preferred : true
|
||||
}
|
||||
this.save()
|
||||
this.emitChanged()
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
const { createHash, randomUUID } = require('node:crypto')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { parseTaskDecision } = require('../collaboration/task-decision.cjs')
|
||||
const {
|
||||
agentRuntimeCapabilities,
|
||||
isCodeReviewAgentKind,
|
||||
@@ -8,7 +11,6 @@ const {
|
||||
nextSessionMeta,
|
||||
normalizeOutcomeRefs,
|
||||
normalizeSessionMeta,
|
||||
shouldRotateSession,
|
||||
} = require('../runs/run-harness.cjs')
|
||||
const {
|
||||
canonicalJson,
|
||||
@@ -47,6 +49,12 @@ const OUTCOME_REF_FIELDS = Object.freeze([
|
||||
'workflowOutcomeRefs',
|
||||
])
|
||||
|
||||
function extractReportedOutputPaths(text) {
|
||||
if (typeof text !== 'string') return []
|
||||
const matches = text.match(/(?:\/(?:Users|home|tmp|private\/tmp)\/[^\s"'<>`\]\[)]+)/g) || []
|
||||
return [...new Set(matches.map(value => value.replace(/[.,;:!?]+$/, '')))]
|
||||
}
|
||||
|
||||
function mergeOutcomeRefs(sources, options = {}) {
|
||||
const merged = {}
|
||||
const seen = new Map(OUTCOME_REF_FIELDS.map(field => [field, new Set()]))
|
||||
@@ -69,21 +77,11 @@ function mergeOutcomeRefs(sources, options = {}) {
|
||||
return normalizeOutcomeRefs(merged)
|
||||
}
|
||||
|
||||
function canUseNativeMediaFallback(error) {
|
||||
const code = String(error?.code || error?.message || '')
|
||||
return /^(?:MEDIA_GENERATION_(?:PROVIDER_UNAVAILABLE|MODEL_UNAVAILABLE|NETWORK_FAILED|INVALID_RESPONSE|FAILED|DOWNLOAD_FAILED)|MEDIA_GENERATION_(?:HTTP|DOWNLOAD_HTTP)_\d+)$/.test(code)
|
||||
}
|
||||
|
||||
function agentReturnedMediaProviderFailure(text) {
|
||||
const normalized = String(text || '').replace(/\s+/g, ' ').trim()
|
||||
if (!normalized) return false
|
||||
return /configured providers do not offer the required media model/i.test(normalized)
|
||||
|| /use a provider credential with access to (?:that|the) image, audio, or video model/i.test(normalized)
|
||||
|| /provider.+(?:does not|do not).+(?:offer|support).+(?:media|image|audio|video).+model/i.test(normalized)
|
||||
}
|
||||
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
|
||||
@@ -188,6 +186,7 @@ function parseV4CollaborationReceiptStrict(result, required = false, expectedPha
|
||||
const summary = boundedReceiptText(receipt.summary)
|
||||
const allowed = new Set([
|
||||
'version', 'phase', 'summary',
|
||||
...(phase === 'discussion' ? ['taskDecision'] : []),
|
||||
...(['challenge', 'verification'].includes(phase) ? ['verdict'] : []),
|
||||
...(phase === 'proposal'
|
||||
? ['capabilities', 'intendedWork', 'deliverables', 'dependencies'] : []),
|
||||
@@ -242,6 +241,8 @@ function parseV4CollaborationReceiptStrict(result, required = false, expectedPha
|
||||
version: 1,
|
||||
phase,
|
||||
summary,
|
||||
...(phase === 'discussion' && receipt.taskDecision != null
|
||||
? { taskDecision: parseTaskDecision(receipt.taskDecision) } : {}),
|
||||
...(['challenge', 'verification'].includes(phase) ? { verdict: receipt.verdict } : {}),
|
||||
...(phase === 'proposal' ? {
|
||||
capabilities: proposalList('capabilities', true),
|
||||
@@ -326,7 +327,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 +341,7 @@ function isolatedInvocationContext(context) {
|
||||
return {
|
||||
promptOverride,
|
||||
contextPackId,
|
||||
policy: context.sessionPolicy,
|
||||
policy: ['isolated', 'frozen'].includes(context?.sessionPolicy) ? context.sessionPolicy : '',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,7 +353,7 @@ function createdSessionProvenance(group, taskId, stateless = false) {
|
||||
}
|
||||
}
|
||||
return {
|
||||
scope: group.conversationType === 'direct' ? 'conversation' : 'task',
|
||||
scope: 'conversation',
|
||||
reuse: false,
|
||||
origin: 'created',
|
||||
originTaskId: taskId || null,
|
||||
@@ -526,7 +528,7 @@ class LocalWorkspaceAgentInvocation {
|
||||
this.completeHumanGateContinuation = options.completeHumanGateContinuation
|
||||
this.connectorRuntime = options.connectorRuntime || null
|
||||
this.attachmentSupport = options.attachmentSupport || (() => ({}))
|
||||
this.generateMedia = typeof options.generateMedia === 'function' ? options.generateMedia : null
|
||||
this.activeAgentSnapshots = new Map()
|
||||
}
|
||||
|
||||
commitSessionState(mutator) {
|
||||
@@ -548,9 +550,24 @@ class LocalWorkspaceAgentInvocation {
|
||||
const activeRun = this.activeRuns.get(group.id)
|
||||
const taskId = cleanText(activeRun?.taskId || context.taskId || threadRootId, 120)
|
||||
if (!taskId) throw new Error('LOCAL_RUN_TASK_INVALID')
|
||||
let workspacePath = group.workdir ? path.resolve(group.workdir) : group.id
|
||||
try { workspacePath = fs.realpathSync(workspacePath) } catch { /* runtime validates unavailable directories */ }
|
||||
// Direct chats are independent execution surfaces. Scope their scheduler
|
||||
// resource to the chat so one writable chat cannot block another Agent.
|
||||
const schedulerWorkspaceKey = group.conversationType === 'direct'
|
||||
? `${workspacePath}\u0000direct:${group.id}`
|
||||
: workspacePath
|
||||
const workspaceKey = createHash('sha256')
|
||||
.update(String(group.workdir || group.id))
|
||||
.update(String(schedulerWorkspaceKey))
|
||||
.digest('hex')
|
||||
const writerKind = cleanText(context.singleWriterKind || context.writerKind, 80)
|
||||
const permissionMode = group.allowWrite === true
|
||||
&& (context.permissionMode == null || context.permissionMode === 'workspace-write')
|
||||
&& (!writerKind || writerKind === kind)
|
||||
&& isolatedInvocationContext(context)?.policy !== 'isolated'
|
||||
&& !isCodeReviewAgentKind(kind)
|
||||
? 'workspace-write' : 'read-only'
|
||||
context = { ...context, permissionMode }
|
||||
const agentController = new AbortController()
|
||||
const registration = { agentRunId: '' }
|
||||
let queuedAbortHandler = null
|
||||
@@ -564,6 +581,7 @@ class LocalWorkspaceAgentInvocation {
|
||||
return await this.runScheduler.withLease({
|
||||
taskId,
|
||||
workspaceKey,
|
||||
permissionMode,
|
||||
signal: agentController.signal,
|
||||
}, (lease) => {
|
||||
if (signal && queuedAbortHandler) {
|
||||
@@ -593,7 +611,24 @@ class LocalWorkspaceAgentInvocation {
|
||||
async invokeLeased(
|
||||
group, kind, mode, signal, threadRootId = '', context = {}, invocation = {},
|
||||
) {
|
||||
const agent = this.detectedAgents().find(item => item.kind === kind && item.available)
|
||||
const detected = this.detectedAgents()
|
||||
const activeRun = this.activeRuns.get(group.id)
|
||||
const candidate = detected.find(item => item.kind === kind)
|
||||
const snapshotKey = `${group.id}:${activeRun?.runId || 'active'}:${kind}`
|
||||
if (candidate) this.activeAgentSnapshots.set(snapshotKey, { ...candidate })
|
||||
const snapshot = this.activeAgentSnapshots.get(snapshotKey)
|
||||
const agent = detected.find(item => item.kind === kind && item.available)
|
||||
|| (activeRun?.targetKinds?.includes(kind)
|
||||
&& candidate
|
||||
&& candidate.installed !== false
|
||||
&& candidate.compatibilityState !== 'incompatible'
|
||||
&& candidate.credentialState !== 'missing'
|
||||
&& candidate.availabilitySource !== 'runtime-auth-failure'
|
||||
&& candidate.availabilitySource !== 'none'
|
||||
? candidate
|
||||
: activeRun?.targetKinds?.includes(kind) && !candidate && snapshot
|
||||
? snapshot
|
||||
: null)
|
||||
if (!agent) throw new Error('LOCAL_AGENT_UNAVAILABLE')
|
||||
const reviewOnly = isCodeReviewAgentKind(kind)
|
||||
const invocationContext = isolatedInvocationContext(context)
|
||||
@@ -631,7 +666,6 @@ class LocalWorkspaceAgentInvocation {
|
||||
})
|
||||
}
|
||||
const state = this.state()
|
||||
const activeRun = this.activeRuns.get(group.id)
|
||||
const taskId = cleanText(activeRun?.taskId || context.taskId || threadRootId, 120)
|
||||
const round = mode === 'auto' ? (activeRun?.currentRound || 1) : 0
|
||||
const responseVersionRootId = cleanText(context.responseVersionRootId, 100)
|
||||
@@ -671,9 +705,9 @@ class LocalWorkspaceAgentInvocation {
|
||||
}
|
||||
: null
|
||||
const idempotencyMode = agent.idempotencyMode === 'durable' ? 'durable' : 'none'
|
||||
const resolvedSession = reviewOnly || isolated
|
||||
const resolvedSession = reviewOnly
|
||||
? {
|
||||
key: this.sessionKey(group.id, kind, group.conversationType === 'direct' ? '' : taskId),
|
||||
key: this.sessionKey(group.id, kind),
|
||||
sessionRef: '',
|
||||
sessionMeta: {},
|
||||
provenance: createdSessionProvenance(group, taskId, true),
|
||||
@@ -695,7 +729,9 @@ class LocalWorkspaceAgentInvocation {
|
||||
&& !resumedConnectorGate
|
||||
? context.resumedGate
|
||||
: null
|
||||
const sessionNeedsRotation = sessionRef && shouldRotateSession(sessionMeta)
|
||||
// A conversation owns one native session for its lifetime. Rebuild only
|
||||
// when the Agent reports that the existing native session is invalid.
|
||||
const sessionNeedsRotation = false
|
||||
if (resumedPermission) {
|
||||
const requestHash = sha256(canonicalJson(resumedPermission.request))
|
||||
const persistedBinding = {
|
||||
@@ -740,7 +776,7 @@ class LocalWorkspaceAgentInvocation {
|
||||
const hermesNeedsLegacy = kind === 'hermes' && sessionTransport === 'acp'
|
||||
&& (!HERMES_WORKSPACE_ACP_ENABLED
|
||||
|| agent.acpAvailable === false
|
||||
|| (context.attachments || []).length > 0)
|
||||
)
|
||||
if (resumedPermission && hermesNeedsLegacy) {
|
||||
throw new Error('LOCAL_RUN_PERMISSION_RESUME_UNAVAILABLE')
|
||||
}
|
||||
@@ -783,7 +819,13 @@ class LocalWorkspaceAgentInvocation {
|
||||
idempotencyMode,
|
||||
})
|
||||
}
|
||||
let transcriptAfterKind = !sessionRotated && storedSessionRef && storedSessionRef === sessionRef
|
||||
const knownSessionHistory = group.conversationType === 'direct'
|
||||
|| sessionProvenance.completeness !== 'unknown-legacy'
|
||||
const sameTaskSession = !taskId
|
||||
|| taskId === sessionMeta.originTaskId
|
||||
|| (Array.isArray(sessionMeta.inheritedTaskIds) && sessionMeta.inheritedTaskIds.includes(taskId))
|
||||
let transcriptAfterKind = knownSessionHistory && !sessionRotated && storedSessionRef && storedSessionRef === sessionRef
|
||||
&& (!sessionMeta.lastDeliveredMessageId || !sameTaskSession)
|
||||
? kind
|
||||
: ''
|
||||
// Group Sessions already contain the immutable bootstrap instructions after
|
||||
@@ -792,9 +834,13 @@ class LocalWorkspaceAgentInvocation {
|
||||
let promptMode = group.conversationType !== 'direct'
|
||||
&& Boolean(sessionRef)
|
||||
&& !sessionRotated
|
||||
&& knownSessionHistory
|
||||
&& sessionMeta.turns > 0
|
||||
? 'continuation'
|
||||
: 'bootstrap'
|
||||
const sessionContextOptions = (!isolated && !frozen && promptMode === 'continuation' && sameTaskSession)
|
||||
? { ...(context.contextOptions || {}), afterMessageId: sessionMeta.lastDeliveredMessageId || '' }
|
||||
: (context.contextOptions || {})
|
||||
let packedContext = isolated || frozen
|
||||
? {
|
||||
text: v4Prompt,
|
||||
@@ -815,7 +861,7 @@ class LocalWorkspaceAgentInvocation {
|
||||
continuationText: v4Prompt,
|
||||
}
|
||||
: this.packedPromptContext(
|
||||
group.id, transcriptAfterKind, threadRootId, context.contextOptions || {},
|
||||
group.id, transcriptAfterKind, threadRootId, sessionContextOptions,
|
||||
)
|
||||
if (!isolated && !frozen) {
|
||||
packedContext = withCollaborationPackage(
|
||||
@@ -1103,8 +1149,6 @@ class LocalWorkspaceAgentInvocation {
|
||||
let outputBaseline = null
|
||||
let artifactOutputBaseline = null
|
||||
let stagedInputs = null
|
||||
let generatedMedia = null
|
||||
let nativeMediaFallback = false
|
||||
let result
|
||||
let operationStarted = false
|
||||
let sideEffectsStarted = false
|
||||
@@ -1155,6 +1199,12 @@ class LocalWorkspaceAgentInvocation {
|
||||
...(Number.isInteger(context.executionSequence)
|
||||
? { executionSequence: context.executionSequence }
|
||||
: {}),
|
||||
...(v4AgentSlotBinding
|
||||
? {
|
||||
operationId: v4AgentSlotBinding.operationId,
|
||||
snapshotHash: v4AgentSlotBinding.snapshotHash,
|
||||
}
|
||||
: {}),
|
||||
...runtimeContext,
|
||||
...deliveryContext,
|
||||
...connectorContext,
|
||||
@@ -1208,21 +1258,6 @@ class LocalWorkspaceAgentInvocation {
|
||||
/* artifact output capture is best effort */
|
||||
}
|
||||
}
|
||||
if (allowWrite && context.mediaRequest && this.generateMedia) {
|
||||
sideEffectsStarted = true
|
||||
try {
|
||||
generatedMedia = await abortableOperation(() => this.generateMedia({
|
||||
kind,
|
||||
request: context.mediaRequest,
|
||||
workdir: group.workdir,
|
||||
signal: agentController.signal,
|
||||
onEvent: emitRuntimeEvent,
|
||||
}), agentController.signal)
|
||||
} catch (error) {
|
||||
if (agentController.signal.aborted || !canUseNativeMediaFallback(error)) throw error
|
||||
nativeMediaFallback = true
|
||||
}
|
||||
}
|
||||
const nativeImageLimit = Math.max(
|
||||
0,
|
||||
Math.floor(Number(this.attachmentSupport(kind)?.image) || 0),
|
||||
@@ -1243,24 +1278,21 @@ class LocalWorkspaceAgentInvocation {
|
||||
].join('\n')
|
||||
const buildPrompt = (afterKind, contextPackage) => isolated || frozen
|
||||
? v4Prompt
|
||||
: group.conversationType === 'direct'
|
||||
? (sessionRotated
|
||||
? (contextPackage.continuationText || contextPackage.stableText
|
||||
|| contextPackage.currentTaskText)
|
||||
: contextPackage.currentTaskText)
|
||||
: [
|
||||
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.`
|
||||
: '',
|
||||
nativeMediaFallback
|
||||
? [
|
||||
'Meldwork shared media generator was unavailable. Use your native media-generation tools or installed local skills to fulfill the current request.',
|
||||
'Write or copy every real generated media file into .meldwork-output/ so Meldwork can validate and attach it to the reply.',
|
||||
'Do not return only a prompt and do not claim success unless a real media file was created. If no native media tool is available, explain that accurately.',
|
||||
].join('\n')
|
||||
: '',
|
||||
runtimeInstruction ? `Harness recovery task:\n${runtimeInstruction}` : '',
|
||||
regenerationInstruction
|
||||
? `Fresh response instruction:\n${regenerationInstruction}`
|
||||
@@ -1410,7 +1442,7 @@ class LocalWorkspaceAgentInvocation {
|
||||
onSessionRef: (nextSessionRef, metadata = {}) => {
|
||||
if (agentCallbacksClosed || agentController.signal.aborted) return
|
||||
noteWatchdogProgress()
|
||||
if (reviewOnly || isolated) return
|
||||
if (reviewOnly) return
|
||||
const transport = ['legacy', 'acp'].includes(metadata?.transport)
|
||||
? metadata.transport
|
||||
: ''
|
||||
@@ -1423,7 +1455,7 @@ class LocalWorkspaceAgentInvocation {
|
||||
}))
|
||||
},
|
||||
onSessionInvalidated: () => {
|
||||
if (kind !== 'hermes' || isolated || resumedPermission) return null
|
||||
if (kind !== 'hermes' || reviewOnly || resumedPermission) return null
|
||||
return { prompt: rebuildFreshSession() }
|
||||
},
|
||||
signal: agentController.signal,
|
||||
@@ -1507,11 +1539,12 @@ class LocalWorkspaceAgentInvocation {
|
||||
? { maxTurns: context.maxInternalTurns }
|
||||
: {}),
|
||||
attachments: isolated || frozen ? [] : (stagedInputs?.nativeImagePaths || []),
|
||||
// ACP runtimes are shared by the stable group/task session key. OpenClaw
|
||||
// is excluded because its gateway wrapper owns the process lifetime and
|
||||
// is intentionally torn down after each invocation.
|
||||
...(kind !== 'openclaw' ? { acpPersistenceKey: key } : {}),
|
||||
...(kind === 'hermes'
|
||||
? {
|
||||
acpPersistenceKey: key,
|
||||
hermesAcpAvailable: HERMES_WORKSPACE_ACP_ENABLED && agent.acpAvailable !== false,
|
||||
}
|
||||
? { hermesAcpAvailable: HERMES_WORKSPACE_ACP_ENABLED && agent.acpAvailable !== false }
|
||||
: {}),
|
||||
}
|
||||
const runCurrentSession = () => {
|
||||
@@ -1722,18 +1755,6 @@ class LocalWorkspaceAgentInvocation {
|
||||
? parseAutoReply(collaborationReply.text)
|
||||
: { text: collaborationReply.text, consensus: false }
|
||||
if (!reply.text) throw new Error('LOCAL_AGENT_EMPTY_RESPONSE')
|
||||
if (allowWrite && context.mediaRequest && this.generateMedia && nativeMediaFallback
|
||||
&& !generatedMedia && agentReturnedMediaProviderFailure(reply.text)) {
|
||||
sideEffectsStarted = true
|
||||
generatedMedia = await abortableOperation(() => this.generateMedia({
|
||||
kind,
|
||||
request: context.mediaRequest,
|
||||
workdir: group.workdir,
|
||||
signal: agentController.signal,
|
||||
onEvent: emitRuntimeEvent,
|
||||
}), agentController.signal)
|
||||
reply.text = `Meldwork generated and attached ${generatedMedia.filename}.`
|
||||
}
|
||||
const progress = attemptProgress.length ? attemptProgress : result.progress
|
||||
const toolCalls = cleanProgressSteps(progress).map(step => ({
|
||||
...step,
|
||||
@@ -1773,6 +1794,7 @@ class LocalWorkspaceAgentInvocation {
|
||||
baseline: outputBaseline,
|
||||
startedAt,
|
||||
agentKind: kind,
|
||||
reportedPaths: extractReportedOutputPaths(reply.text),
|
||||
signal: agentController.signal,
|
||||
}))
|
||||
importPromise.catch(() => {})
|
||||
@@ -1851,6 +1873,8 @@ class LocalWorkspaceAgentInvocation {
|
||||
promptHash: sha256(prompt),
|
||||
externalRunRef: result.externalRunRef,
|
||||
outcomeRefs: auditOutcomeRefs,
|
||||
...(collaborationReply.collaboration?.taskDecision
|
||||
? { taskDecision: collaborationReply.collaboration.taskDecision } : {}),
|
||||
})
|
||||
const messageMetadata = {
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
@@ -1881,13 +1905,14 @@ class LocalWorkspaceAgentInvocation {
|
||||
pendingMessage.metadata,
|
||||
)
|
||||
: null
|
||||
if (!reviewOnly && !isolated) {
|
||||
if (!reviewOnly) {
|
||||
this.persistSessionState(key, result.sessionRef || sessionRef, completedSessionMeta(
|
||||
sessionMeta, sessionProvenance, taskId, {
|
||||
promptChars: prompt.length,
|
||||
replyChars: reply.text.length,
|
||||
rotated: sessionRotated,
|
||||
transport: sessionTransport,
|
||||
lastDeliveredMessageId: packedContext.latestMessageId || sessionMeta.lastDeliveredMessageId,
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -1934,7 +1959,7 @@ class LocalWorkspaceAgentInvocation {
|
||||
? agentStoppedError()
|
||||
: caughtError
|
||||
if (credentialFailure(error) && context.deferCredentialFailure !== true) {
|
||||
this.markRuntimeCredential(kind, 'missing')
|
||||
this.markRuntimeCredential(kind, 'unknown')
|
||||
}
|
||||
const status = parentTimedOut
|
||||
? 'timeout'
|
||||
@@ -1983,25 +2008,33 @@ class LocalWorkspaceAgentInvocation {
|
||||
}
|
||||
|
||||
resetSession(group, kind, rotateOpenClaw = true, taskId = '') {
|
||||
const scopedTaskId = group.conversationType === 'direct' ? '' : cleanText(taskId, 120)
|
||||
const key = this.sessionKey(group.id, kind, scopedTaskId)
|
||||
const key = this.sessionKey(group.id, kind)
|
||||
const legacyPrefix = `${group.id}:${kind}:thread:`
|
||||
return this.commitSessionState((state) => {
|
||||
const legacyKeys = new Set([
|
||||
key,
|
||||
...(taskId ? [this.sessionKey(group.id, kind, taskId)] : []),
|
||||
...state.messages.filter(message => message.groupId === group.id && message.role === 'user')
|
||||
.map(message => this.sessionKey(group.id, kind, message.id)),
|
||||
])
|
||||
const belongsToSession = candidate => legacyKeys.has(candidate)
|
||||
|| candidate.startsWith(legacyPrefix)
|
||||
|| (candidate.startsWith(`${group.id}:task:`) && candidate.endsWith(`:${kind}`))
|
||||
let changed = false
|
||||
for (const candidate of Object.keys(state.sessions)) {
|
||||
if (candidate !== key && !candidate.startsWith(legacyPrefix)) continue
|
||||
if (!belongsToSession(candidate)) continue
|
||||
delete state.sessions[candidate]
|
||||
changed = true
|
||||
}
|
||||
for (const candidate of Object.keys(state.sessionMeta)) {
|
||||
if (candidate !== key && !candidate.startsWith(legacyPrefix)) continue
|
||||
if (!belongsToSession(candidate)) continue
|
||||
delete state.sessionMeta[candidate]
|
||||
changed = true
|
||||
}
|
||||
if (kind === 'openclaw' && rotateOpenClaw) {
|
||||
const generation = randomUUID().replace(/[^a-zA-Z0-9]/g, '').slice(0, 12) || 'session'
|
||||
const provenance = createdSessionProvenance(group, cleanText(taskId, 120))
|
||||
state.sessions[key] = this.openClawSessionRef(group, generation, scopedTaskId)
|
||||
state.sessions[key] = this.openClawSessionRef(group, generation)
|
||||
state.sessionMeta[key] = normalizeSessionMeta(provenanceMeta(provenance))
|
||||
changed = true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
const { parseTaskDecision } = require('../collaboration/task-decision.cjs')
|
||||
|
||||
const v4NaturalDecisionMethods = {
|
||||
v4NaturalRouteDecision(decision, activeKinds) {
|
||||
const participants = Array.isArray(activeKinds) ? activeKinds : []
|
||||
if (decision == null) return { status: 'none', kinds: [] }
|
||||
let parsed
|
||||
try { parsed = parseTaskDecision(decision) }
|
||||
catch { return { status: 'invalid', kinds: [] } }
|
||||
const requested = parsed.nextKinds || []
|
||||
if (requested.some(kind => !participants.includes(kind))) return { status: 'invalid', kinds: [] }
|
||||
if (!requested.length) return { status: 'none', kinds: [] }
|
||||
return { status: 'valid', kinds: participants.filter(kind => requested.includes(kind)) }
|
||||
},
|
||||
|
||||
async v4NaturalRoundOutcome(group, controller, threadRootId, round, activeKinds) {
|
||||
const messages = this.v4NaturalDiscussionRoundMessages(group, controller, threadRootId, round)
|
||||
const owner = this.v4NaturalOwner(group, controller, threadRootId)
|
||||
const routes = messages.map(message => this.v4NaturalRouteDecision(
|
||||
message.trace?.context?.taskDecision, activeKinds,
|
||||
))
|
||||
if (routes.some(route => route.status === 'invalid')) {
|
||||
this.addMessage(group.id, 'system', 'Discussion stopped without a valid task completion decision.',
|
||||
'', threadRootId, { key: 'system.autoTaskDecisionMissing' })
|
||||
return { status: 'partial' }
|
||||
}
|
||||
// A peer's explicit blocker is a task outcome, not an empty routing choice.
|
||||
// Without this guard the delivery owner could complete the run while a
|
||||
// participant had already reported that the work was blocked.
|
||||
const peerBlocker = messages.find(message => {
|
||||
if (message.agentKind === owner) return false
|
||||
const status = message.trace?.context?.taskDecision?.status
|
||||
return status === 'blocked' || status === 'needs-human'
|
||||
})
|
||||
if (peerBlocker) {
|
||||
const decision = peerBlocker.trace?.context?.taskDecision
|
||||
this.addMessage(group.id, 'system', decision.reason, '', threadRootId, {
|
||||
key: decision.status === 'needs-human' ? 'system.autoTaskNeedsHuman' : 'system.autoTaskBlocked',
|
||||
params: { reason: decision.reason },
|
||||
})
|
||||
return { status: 'partial' }
|
||||
}
|
||||
const nextKinds = this.v4NaturalNextKinds(group, controller, threadRootId, round, activeKinds)
|
||||
if (nextKinds.length) return { nextKinds, ownerReview: false }
|
||||
if (!activeKinds.includes(owner)) return { status: 'partial' }
|
||||
const ownerMessage = messages.find(message => message.agentKind === owner)
|
||||
const decision = ownerMessage?.trace?.context?.taskDecision
|
||||
if (decision?.handoffTo && !activeKinds.includes(decision.handoffTo)) return { status: 'partial' }
|
||||
if (decision?.status === 'continue') return { nextKinds: [owner], ownerReview: false }
|
||||
const sawAllContributions = messages.length === 1
|
||||
|| (controller.discussionStyle === 'sequential' && messages.at(-1)?.agentKind === owner)
|
||||
if (!ownerMessage || !sawAllContributions) return { nextKinds: [owner], ownerReview: true }
|
||||
if (!decision) {
|
||||
this.addMessage(group.id, 'system', 'Discussion stopped without a valid task completion decision.',
|
||||
'', threadRootId, { key: 'system.autoTaskDecisionMissing' })
|
||||
return { status: 'partial' }
|
||||
}
|
||||
if (decision.status === 'completed') return { status: controller.failedKinds.length ? 'partial' : 'completed' }
|
||||
if (decision.status === 'needs-human' && this.requestTaskDecision) {
|
||||
const response = await this.requestTaskDecision(group, controller, ownerMessage)
|
||||
if (response?.status === 'approved') return { nextKinds: [owner], ownerReview: true }
|
||||
if (response?.status === 'rejected') {
|
||||
controller.stopReason = 'human_gate_rejected'
|
||||
return { status: 'stopped' }
|
||||
}
|
||||
}
|
||||
this.addMessage(group.id, 'system', decision.reason, '', threadRootId, {
|
||||
key: decision.status === 'needs-human' ? 'system.autoTaskNeedsHuman' : 'system.autoTaskBlocked',
|
||||
params: { reason: decision.reason },
|
||||
})
|
||||
return { status: 'partial' }
|
||||
},
|
||||
|
||||
v4NaturalNextKinds(group, controller, threadRootId, round, activeKinds) {
|
||||
const current = this.v4NaturalDiscussionRoundMessages(group, controller, threadRootId, round)
|
||||
const selected = new Set(current.flatMap(message => (
|
||||
this.v4NaturalRouteDecision(message.trace?.context?.taskDecision, activeKinds).kinds
|
||||
.filter(kind => kind !== message.agentKind)
|
||||
)))
|
||||
if (!selected.size) return []
|
||||
return activeKinds.filter(kind => selected.has(kind))
|
||||
},
|
||||
|
||||
v4NaturalOwner(group, controller, threadRootId) {
|
||||
let owner = controller.targetKinds[0] || ''
|
||||
for (const message of this.state().messages) {
|
||||
if (message.groupId !== group.id || message.threadRootId !== threadRootId
|
||||
|| message.role !== 'agent' || message.agentKind !== owner
|
||||
|| message.trace?.phase !== 'discussion'
|
||||
|| !this.v4NaturalMessageMatchesBinding(message, controller)) continue
|
||||
const decision = message.trace?.context?.taskDecision
|
||||
if (decision?.status === 'continue' && decision.handoffTo
|
||||
&& controller.targetKinds.includes(decision.handoffTo)) owner = decision.handoffTo
|
||||
}
|
||||
return owner
|
||||
},
|
||||
|
||||
v4NaturalDiscussionIsRepeating(group, controller, threadRootId, round, activeKinds) {
|
||||
const window = Math.max(4, activeKinds.length * 2)
|
||||
if (round < window + 2) return false
|
||||
const firstRound = round - window + 1
|
||||
const previous = new Map()
|
||||
const observedRounds = new Set()
|
||||
const messages = this.state().messages
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index]
|
||||
const messageRound = Number(message.trace?.round) || 0
|
||||
if (message.groupId !== group.id || message.threadRootId !== threadRootId
|
||||
|| message.role !== 'agent' || message.trace?.phase !== 'discussion'
|
||||
|| message.trace?.runId !== controller.runId || messageRound < 2
|
||||
|| messageRound > round || !this.v4NaturalMessageMatchesBinding(message, controller)) continue
|
||||
const contribution = this.v4NaturalMessageContent(message)
|
||||
if (contribution.partial) return false
|
||||
const content = contribution.text
|
||||
.replace(/@[A-Za-z0-9][A-Za-z0-9_-]*/gu, '')
|
||||
.replace(/\s+/gu, ' ').trim()
|
||||
if (previous.has(message.agentKind) && previous.get(message.agentKind) !== content) return false
|
||||
if (messageRound >= firstRound) {
|
||||
observedRounds.add(messageRound)
|
||||
previous.set(message.agentKind, content)
|
||||
} else {
|
||||
previous.delete(message.agentKind)
|
||||
if (observedRounds.size === window && previous.size === 0) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
|
||||
v4NaturalStopRepeatingDiscussion(group, controller, threadRootId, round, activeKinds) {
|
||||
if (!this.v4NaturalDiscussionIsRepeating(group, controller, threadRootId, round, activeKinds)) return false
|
||||
this.addMessage(
|
||||
group.id, 'system',
|
||||
'Automatic discussion stopped because repeated handoffs produced no new contribution.',
|
||||
'', threadRootId, { key: 'system.autoDiscussionStalled' },
|
||||
)
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = { v4NaturalDecisionMethods }
|
||||
@@ -26,6 +26,9 @@ const CONTINUATION_STABLE_CONTEXT_TEXT_LIMIT = 1400
|
||||
const CONTINUATION_RECENT_CONTEXT_TEXT_LIMIT = 4600
|
||||
const CURRENT_TASK_TEXT_LIMIT = 6000
|
||||
const V4_SNAPSHOT_HISTORY_LIMIT = 16
|
||||
const NATURAL_TRANSCRIPT_TEXT_LIMIT = 48000
|
||||
const NATURAL_TRANSCRIPT_ENTRY_LIMIT = 20000
|
||||
const NATURAL_TRANSCRIPT_OMISSION_NOTICE = 'This transcript is partial: some turns or parts of turns were omitted to fit the context budget. Omission is not evidence of agreement or completion. Ask peers to restate any missing evidence needed to judge the original task.'
|
||||
const V4_SNAPSHOT_FIELDS = [
|
||||
'group', 'history', 'messageId', 'phase', 'snapshotHash',
|
||||
'skillHintsByKind', 'targetKinds', 'taskId', 'taskText', 'version', 'writerKind',
|
||||
@@ -53,6 +56,50 @@ function unlimitedReviewContract(enabled = false) {
|
||||
return enabled ? UNLIMITED_REVIEW_CONTRACT : ''
|
||||
}
|
||||
|
||||
function packNaturalDiscussionTranscript(entries, options = {}) {
|
||||
const prefix = String(options.prefix || '')
|
||||
const latestByKind = new Map(entries.map((entry, index) => [entry.agentKind, index]))
|
||||
let excerpted = entries.some(entry => entry.partial === true)
|
||||
const completeBodies = new Map()
|
||||
const packed = packContextEntries(entries.map((entry, index) => {
|
||||
const heading = `Round ${Number(entry.round) || 0} - @${entry.agentKind}\n`
|
||||
let text = String(entry.text || '').trim()
|
||||
const limit = NATURAL_TRANSCRIPT_ENTRY_LIMIT - heading.length
|
||||
if (text.length > limit) {
|
||||
const marker = '\n[Middle of this turn omitted]\n'
|
||||
const edge = Math.floor((limit - marker.length) / 2)
|
||||
// Preserve closing requests as well as opening findings, without inventing a summary.
|
||||
const head = text.slice(0, edge).replace(/[\uD800-\uDBFF]$/u, '')
|
||||
const tail = text.slice(-edge).replace(/^[\uDC00-\uDFFF]/u, '')
|
||||
text = `${head}${marker}${tail}`
|
||||
excerpted = true
|
||||
} else if (!entry.partial) {
|
||||
completeBodies.set(entry.id, `${heading}${text}`)
|
||||
}
|
||||
return {
|
||||
id: entry.id,
|
||||
text: `${heading}${text}`,
|
||||
priority: index === entries.length - 1 ? 3
|
||||
: latestByKind.get(entry.agentKind) === index ? 2 : 1,
|
||||
}
|
||||
}), {
|
||||
budget: NATURAL_TRANSCRIPT_TEXT_LIMIT - NATURAL_TRANSCRIPT_OMISSION_NOTICE.length - prefix.length - 2,
|
||||
entryLimit: NATURAL_TRANSCRIPT_ENTRY_LIMIT,
|
||||
maxEntries: 100,
|
||||
})
|
||||
const text = [
|
||||
prefix,
|
||||
excerpted || packed.omittedCount ? NATURAL_TRANSCRIPT_OMISSION_NOTICE : '',
|
||||
packed.text,
|
||||
].filter(Boolean).join('\n')
|
||||
return options.withEntries ? {
|
||||
text,
|
||||
completeMessageIds: packed.sourceEntries.filter(entry => (
|
||||
completeBodies.get(entry.id) === entry.text
|
||||
)).map(entry => entry.id),
|
||||
} : text
|
||||
}
|
||||
|
||||
function v4Snapshot({
|
||||
state, group, taskId, targetKinds, message, skillHintsByKind,
|
||||
phase = 'proposal', writerKind = '',
|
||||
@@ -230,8 +277,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,13 +318,18 @@ 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,
|
||||
'Answer the user naturally in Markdown. Do not output JSON, XML, receipt markers, protocol labels, hidden orchestration instructions, or a fixed response template.',
|
||||
phase === 'proposal'
|
||||
? 'This invocation is read-only. Its deliverable is your initial analysis and proposed next actions. Return that contribution when ready; any workspace changes belong to a later invocation that explicitly grants write permission. Do not attempt or delegate writes from this invocation, or wait here for write permission.'
|
||||
: '',
|
||||
'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.',
|
||||
phase === 'discussion'
|
||||
? 'Answer the user naturally in Markdown. Keep orchestration instructions out of the answer. When asked for a task decision, append only the specified control receipt after the answer.'
|
||||
: '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')
|
||||
}
|
||||
@@ -369,12 +422,8 @@ function unknownLegacySessionMeta(meta) {
|
||||
function resolveSessionState({
|
||||
state, group, kind, threadRootId = '', taskId = '', save,
|
||||
}) {
|
||||
const scopedTaskId = group.conversationType === 'direct'
|
||||
? ''
|
||||
: cleanText(taskId || threadRootId, 100)
|
||||
const currentTaskId = cleanText(taskId || threadRootId, 100)
|
||||
const key = sessionKey(group.id, kind, scopedTaskId)
|
||||
const globalKey = sessionKey(group.id, kind)
|
||||
const key = sessionKey(group.id, kind)
|
||||
const legacyPrefix = `${group.id}:${kind}:thread:`
|
||||
let stateChanged = false
|
||||
const validStoredRef = (candidateKey) => {
|
||||
@@ -392,7 +441,7 @@ function resolveSessionState({
|
||||
const legacyBase = `agent:main:desktop-meldwork-${legacyScope}-openclaw`
|
||||
if (candidate === legacyBase || candidate.startsWith(`${legacyBase}-`)) {
|
||||
const generation = candidate.slice(legacyBase.length).replace(/^-/, '')
|
||||
candidate = openClawSessionRef(group, generation, scopedTaskId)
|
||||
candidate = openClawSessionRef(group, generation)
|
||||
state.sessions[candidateKey] = candidate
|
||||
stateChanged = true
|
||||
}
|
||||
@@ -412,42 +461,41 @@ function resolveSessionState({
|
||||
return candidate
|
||||
}
|
||||
|
||||
const currentLegacyKey = scopedTaskId
|
||||
? `${legacyPrefix}${scopedTaskId}`
|
||||
: ''
|
||||
const legacyKeys = [...new Set([
|
||||
...(currentTaskId ? [sessionKey(group.id, kind, currentTaskId), `${legacyPrefix}${currentTaskId}`] : []),
|
||||
...[...(state.messages || [])].reverse()
|
||||
.filter(message => message.groupId === group.id && message.role === 'user')
|
||||
.flatMap(message => [sessionKey(group.id, kind, message.id), `${legacyPrefix}${message.id}`]),
|
||||
...Object.keys(state.sessions).reverse().filter(candidate => (
|
||||
candidate.startsWith(legacyPrefix)
|
||||
|| (candidate.startsWith(`${group.id}:task:`) && candidate.endsWith(`:${kind}`))
|
||||
)),
|
||||
])].filter(candidate => candidate !== key)
|
||||
let migration = ''
|
||||
let sessionReset = false
|
||||
let created = false
|
||||
let stored = validStoredRef(key)
|
||||
if (!stored && currentLegacyKey) {
|
||||
const legacyRef = validStoredRef(currentLegacyKey)
|
||||
if (legacyRef) {
|
||||
for (const legacyKey of legacyKeys) {
|
||||
const legacyRef = validStoredRef(legacyKey)
|
||||
if (!stored && legacyRef) {
|
||||
state.sessions[key] = legacyRef
|
||||
state.sessionMeta[key] = completeSessionMeta(
|
||||
state.sessionMeta[currentLegacyKey], 'task', scopedTaskId,
|
||||
)
|
||||
delete state.sessions[currentLegacyKey]
|
||||
delete state.sessionMeta[currentLegacyKey]
|
||||
const legacyMeta = normalizeSessionMeta(state.sessionMeta[legacyKey])
|
||||
state.sessionMeta[key] = ['task', 'conversation'].includes(legacyMeta.sessionScope)
|
||||
? normalizeSessionMeta({ ...legacyMeta, sessionScope: 'conversation' })
|
||||
: unknownLegacySessionMeta(legacyMeta)
|
||||
stored = legacyRef
|
||||
migration = 'migrated'
|
||||
stateChanged = true
|
||||
}
|
||||
}
|
||||
if (!stored && scopedTaskId && globalKey !== key) {
|
||||
// A conversation-scoped legacy Session has no trustworthy task provenance.
|
||||
// Reusing it for a new root can make the Agent continue an unrelated topic.
|
||||
validStoredRef(globalKey)
|
||||
if (Object.hasOwn(state.sessions, globalKey) || Object.hasOwn(state.sessionMeta, globalKey)) {
|
||||
delete state.sessions[globalKey]
|
||||
delete state.sessionMeta[globalKey]
|
||||
sessionReset = true
|
||||
if (Object.hasOwn(state.sessions, legacyKey) || Object.hasOwn(state.sessionMeta, legacyKey)) {
|
||||
delete state.sessions[legacyKey]
|
||||
delete state.sessionMeta[legacyKey]
|
||||
stateChanged = true
|
||||
}
|
||||
}
|
||||
if (!stored && kind === 'openclaw') {
|
||||
state.sessions[key] = openClawSessionRef(group, '', scopedTaskId)
|
||||
state.sessions[key] = openClawSessionRef(group)
|
||||
state.sessionMeta[key] = completeSessionMeta(
|
||||
state.sessionMeta[key], scopedTaskId ? 'task' : 'conversation', currentTaskId,
|
||||
state.sessionMeta[key], 'conversation', currentTaskId,
|
||||
)
|
||||
stateChanged = true
|
||||
stored = state.sessions[key]
|
||||
@@ -462,7 +510,7 @@ function resolveSessionState({
|
||||
if (stateChanged) save()
|
||||
const provenance = created || !stored
|
||||
? {
|
||||
scope: scopedTaskId ? 'task' : 'conversation',
|
||||
scope: 'conversation',
|
||||
reuse: false,
|
||||
origin: 'created',
|
||||
originTaskId: currentTaskId || null,
|
||||
@@ -486,7 +534,7 @@ function resolveSessionState({
|
||||
inheritedTaskIds: [...(meta.inheritedTaskIds || [])],
|
||||
completeness: meta.provenanceCompleteness || 'partial',
|
||||
}
|
||||
return { key, sessionRef: stored, sessionMeta: meta, provenance, sessionReset }
|
||||
return { key, sessionRef: stored, sessionMeta: meta, provenance, sessionReset: false }
|
||||
}
|
||||
|
||||
function resolveSessionRef(options) {
|
||||
@@ -593,12 +641,14 @@ function stableUserInstructions(state, groupId, threadRootId = '', contextOption
|
||||
function recentTranscriptEntries(state, groupId, afterAgentKind = '', contextOptions = {}) {
|
||||
const messages = scopedConversationMessages(state, groupId, contextOptions)
|
||||
let afterIndex = -1
|
||||
const afterMessageId = cleanText(contextOptions.afterMessageId, 100)
|
||||
if (afterMessageId) afterIndex = messages.findIndex(message => message.id === afterMessageId)
|
||||
if (afterAgentKind) {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index]
|
||||
if (message.agentKind === afterAgentKind
|
||||
&& (message.role === 'agent' || isTracedAgentTerminalMessage(message))) {
|
||||
afterIndex = index
|
||||
afterIndex = Math.max(afterIndex, index)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -645,11 +695,13 @@ function packedPromptContext({
|
||||
excludeResponseVersionRootId = '',
|
||||
focusUserMessageId = '',
|
||||
omitAgentThreadRootId = '',
|
||||
afterMessageId = '',
|
||||
}) {
|
||||
const contextOptions = {
|
||||
beforeMessageId,
|
||||
excludeResponseVersionRootId,
|
||||
focusUserMessageId,
|
||||
afterMessageId: cleanText(afterMessageId, 100),
|
||||
}
|
||||
const scopedMessages = scopedConversationMessages(state, groupId, contextOptions)
|
||||
const focusId = cleanText(focusUserMessageId, 100)
|
||||
@@ -741,6 +793,7 @@ function packedPromptContext({
|
||||
currentTaskText,
|
||||
latestUserLanguage: responseLanguageFromText(latestUserMessage?.content),
|
||||
latestUserMessageId: latestUserMessage?.id || '',
|
||||
latestMessageId: scopedMessages.at(-1)?.id || '',
|
||||
...bootstrapSelection,
|
||||
continuationSourceMessageIds: continuationSelection.sourceMessageIds,
|
||||
continuationSourceEntries: continuationSelection.sourceEntries,
|
||||
@@ -845,6 +898,7 @@ function promptFor({
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
packNaturalDiscussionTranscript,
|
||||
clearSessionState,
|
||||
openClawSessionRef,
|
||||
packedPromptContext,
|
||||
|
||||
@@ -19,7 +19,6 @@ const {
|
||||
terminalRunStatusForReason,
|
||||
} = require('./local-workspace-inputs.cjs')
|
||||
const { MAX_RUN_AGENT_ATTEMPTS } = require('../runs/failure-policy.cjs')
|
||||
const { mediaGenerationRequest } = require('../media/media-generation-request.cjs')
|
||||
const { createTaskGraph } = require('../collaboration/task-graph-records.cjs')
|
||||
const { assertLocalSkillExecution } = require('../skills/local-skill-contract.cjs')
|
||||
const {
|
||||
@@ -45,6 +44,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'
|
||||
@@ -254,7 +255,6 @@ class LocalWorkspaceMessageSubmission {
|
||||
]))
|
||||
return {
|
||||
text,
|
||||
mediaRequest: mediaGenerationRequest(text),
|
||||
attachments,
|
||||
skillHintsByKind,
|
||||
skillHints: targetKinds.flatMap(kind => publicSkillHintsByKind.get(kind) || []),
|
||||
@@ -280,7 +280,7 @@ class LocalWorkspaceMessageSubmission {
|
||||
minContextChars: Math.max(1, cleanText(input.text).length),
|
||||
})
|
||||
const targetKinds = routingDecision.selectedKinds
|
||||
if (mode === 'auto' && targetKinds.length < 2) throw new Error('LOCAL_AUTO_AGENT_COUNT')
|
||||
if (mode === 'auto' && !targetKinds.length) throw new Error('LOCAL_AUTO_AGENT_COUNT')
|
||||
if (input.mentionedAgentKinds != null && !Array.isArray(input.mentionedAgentKinds)) {
|
||||
throw new Error('LOCAL_MESSAGE_TARGET_REQUIRED')
|
||||
}
|
||||
@@ -337,15 +337,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 || (mode === 'auto' && 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 +1441,7 @@ class LocalWorkspaceMessageSubmission {
|
||||
regenerateMessageId,
|
||||
routingDecision,
|
||||
taskGraph,
|
||||
discussionStyle,
|
||||
} = this.validateInput(group, input)
|
||||
const regeneration = this.resolveRegeneration(group, regenerateMessageId, targetKinds)
|
||||
const reservation = this.reserveRun(
|
||||
@@ -1439,6 +1453,7 @@ class LocalWorkspaceMessageSubmission {
|
||||
unlimitedRounds,
|
||||
)
|
||||
reservation.v4 = v4
|
||||
reservation.discussionStyle = discussionStyle
|
||||
try {
|
||||
if (regeneration) reservation.responseVersionRootId = regeneration.responseVersionRootId
|
||||
this.configureRunBudget(reservation, input.budget || {})
|
||||
@@ -1587,7 +1602,6 @@ class LocalWorkspaceMessageSubmission {
|
||||
let activeKinds = [...targetKinds]
|
||||
const pendingKinds = [...targetKinds]
|
||||
const replacementInstructions = new Map()
|
||||
const mediaOwnerKind = prepared.mediaRequest ? targetKinds[0] : ''
|
||||
while (pendingKinds.length) {
|
||||
const kind = pendingKinds.shift()
|
||||
if (!activeKinds.includes(kind)) continue
|
||||
@@ -1619,7 +1633,6 @@ class LocalWorkspaceMessageSubmission {
|
||||
attachmentSnapshots: prepared.attachments,
|
||||
sessionThreadRootId: requestedThreadRootId || threadRootId,
|
||||
runtimeInstruction: replacementInstructions.get(kind) || '',
|
||||
mediaRequest: kind === mediaOwnerKind ? prepared.mediaRequest : null,
|
||||
responseVersionRootId: regeneration?.responseVersionRootId || '',
|
||||
regenerationInstruction: regeneration ? this.regenerationInstruction() : '',
|
||||
contextOptions: regeneration
|
||||
@@ -1764,7 +1777,7 @@ class LocalWorkspaceMessageSubmission {
|
||||
? normalizeTargetKinds(input.targetKinds)
|
||||
: group.agentKinds
|
||||
const targetKinds = [...new Set(requested.filter(kind => group.agentKinds.includes(kind)))]
|
||||
if (targetKinds.length < 2) throw new Error('LOCAL_AUTO_AGENT_COUNT')
|
||||
if (!targetKinds.length) throw new Error('LOCAL_AUTO_AGENT_COUNT')
|
||||
const unlimitedRounds = input.unlimitedRounds === true
|
||||
const maxRounds = unlimitedRounds
|
||||
? 0
|
||||
@@ -1797,6 +1810,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({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { RUN_STATUSES } = require('../runs/run-ledger-records.cjs')
|
||||
const { normalizeSessionMeta } = require('../runs/run-harness.cjs')
|
||||
const {
|
||||
SESSION_KEY,
|
||||
@@ -19,7 +20,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',
|
||||
])
|
||||
@@ -244,9 +245,7 @@ function saveWorkspaceState(storagePath, state) {
|
||||
fs.renameSync(tempPath, storagePath)
|
||||
}
|
||||
|
||||
function durableWaitingRunSnapshots({ state, runLedger, pendingGates, liveRunIds, liveGroupIds }) {
|
||||
let records = []
|
||||
try { records = runLedger?.list?.() || [] } catch { return [] }
|
||||
function durableWaitingRunSnapshots({ state, records, pendingGates, liveRunIds, liveGroupIds }) {
|
||||
const groupIds = new Set(state.groups.map(group => group.id))
|
||||
const pendingById = new Map(pendingGates.map(gate => [gate.gateId, gate]))
|
||||
const projectedGroupIds = new Set()
|
||||
@@ -319,6 +318,23 @@ function workspaceSnapshot({
|
||||
detectedAgents, state, preparingRuns, activeRuns, humanGateCoordinator, runLedger,
|
||||
workspaceRecovery,
|
||||
}) {
|
||||
let records = []
|
||||
try { records = runLedger?.list?.() || [] } catch { /* retain the visible workspace on ledger read failure */ }
|
||||
const groupIds = new Set(state.groups.map(group => group.id))
|
||||
const roots = new Map(state.messages.filter(message => message.role === 'user' && !message.threadRootId)
|
||||
.map(message => [message.id, message.groupId]))
|
||||
const latestOutcomes = new Map()
|
||||
for (const record of records) {
|
||||
if (!groupIds.has(record.groupId) || roots.get(record.threadRootId) !== record.groupId
|
||||
|| !RUN_STATUSES.has(record.status)) continue
|
||||
const previous = latestOutcomes.get(record.threadRootId)
|
||||
if (previous && previous.startedAt >= record.startedAt) continue
|
||||
latestOutcomes.set(record.threadRootId, {
|
||||
groupId: record.groupId, runId: record.runId, threadRootId: record.threadRootId,
|
||||
status: record.status, targetKinds: [...record.targetKinds],
|
||||
startedAt: record.startedAt, finishedAt: record.finishedAt || 0,
|
||||
})
|
||||
}
|
||||
const busyEntries = [
|
||||
...[...preparingRuns.entries()].map(entry => [...entry, 'preparing']),
|
||||
...[...activeRuns.entries()].map(entry => [...entry, 'running']),
|
||||
@@ -330,7 +346,7 @@ function workspaceSnapshot({
|
||||
const liveRunIds = new Set(runEntries.map(([, run]) => run.runId).filter(Boolean))
|
||||
const liveGroupIds = new Set(busyEntries.map(([groupId]) => groupId))
|
||||
const durableRunEntries = durableWaitingRunSnapshots({
|
||||
state, runLedger, pendingGates, liveRunIds, liveGroupIds,
|
||||
state, records, pendingGates, liveRunIds, liveGroupIds,
|
||||
})
|
||||
const liveRunEntries = runEntries.map(([groupId, run, phase]) => {
|
||||
const mode = run.mode === 'auto' ? 'auto' : 'manual'
|
||||
@@ -404,6 +420,7 @@ function workspaceSnapshot({
|
||||
])],
|
||||
humanGates: publicGates,
|
||||
runs: publicRuns,
|
||||
runOutcomes: [...latestOutcomes.values()],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ const { EventEmitter } = require('node:events')
|
||||
const { createHash, randomUUID } = require('node:crypto')
|
||||
const path = require('node:path')
|
||||
const { ContentBlobStore } = require('../attachments/content-blob-store.cjs')
|
||||
const { agentRuntimeCapabilities } = require('../agents/agent-runtime-contract.cjs')
|
||||
const { ContextPackStore } = require('../collaboration/context-pack-store.cjs')
|
||||
const { HumanGateCoordinator } = require('../gates/human-gate-coordinator.cjs')
|
||||
const { HumanGateStore } = require('../gates/human-gate-store.cjs')
|
||||
@@ -66,7 +67,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 +97,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 {
|
||||
@@ -91,7 +115,6 @@ class LocalWorkspace extends EventEmitter {
|
||||
this.storagePath = options.storagePath
|
||||
this.detectAgentsFn = options.detectAgents
|
||||
this.runAgentFn = options.runAgent
|
||||
this.generateMediaFn = options.generateMedia || null
|
||||
this.resolveAttachmentsFn = options.resolveAttachments || (async (attachments) => {
|
||||
if (attachments?.length) throw new Error('LOCAL_ATTACHMENT_STORAGE_UNAVAILABLE')
|
||||
return []
|
||||
@@ -287,10 +310,12 @@ class LocalWorkspace extends EventEmitter {
|
||||
completeHumanGateContinuation: (...args) => this.completeHumanGateContinuation(...args),
|
||||
connectorRuntime: options.connectorRuntime,
|
||||
attachmentSupport: (...args) => this.attachmentSupportFn(...args),
|
||||
generateMedia: (...args) => this.generateMediaFn?.(...args),
|
||||
})
|
||||
this.autoRunner = new LocalWorkspaceAutoRunner({
|
||||
state: () => this.state,
|
||||
canWriteWorkspace: kind => agentRuntimeCapabilities(kind, {
|
||||
agent: this.detectedAgents.find(agent => agent.kind === kind),
|
||||
}).permissionModes.includes('workspace-write'),
|
||||
beginRun: (...args) => this.beginRun(...args),
|
||||
resolveAttachments: (...args) => this.resolveAttachments(...args),
|
||||
validateSkillSelections: (...args) => this.validateSkillSelectionsFn(...args),
|
||||
@@ -316,6 +341,8 @@ class LocalWorkspace extends EventEmitter {
|
||||
hasRunLedger: () => Boolean(this.runLedger),
|
||||
requestHumanGate: (...args) => this.requestHumanGate(...args),
|
||||
completeHumanGateContinuation: (...args) => this.completeHumanGateContinuation(...args),
|
||||
requestTaskDecision: (...args) => this.requestV4TaskDecision(...args),
|
||||
taskDecisionResponses: runId => this.v4TaskDecisionResponses(runId),
|
||||
retryContract: kind => ({
|
||||
idempotencyMode: this.detectedAgents.find(agent => agent.kind === kind)?.idempotencyMode
|
||||
=== 'durable'
|
||||
@@ -476,7 +503,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)
|
||||
@@ -636,6 +663,10 @@ class LocalWorkspace extends EventEmitter {
|
||||
const finalStatus = await this.autoRunner.resume(group, durable, controller)
|
||||
await this.finishRun(group.id, controller, finalStatus)
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
await this.finishRun(group.id, controller, terminalRunStatusForReason(controller.stopReason))
|
||||
return
|
||||
}
|
||||
controller.stopReason = String(error?.message || 'LOCAL_RUN_CONTINUATION_INVALID')
|
||||
await this.finishRun(group.id, controller, 'failed')
|
||||
}
|
||||
@@ -775,7 +806,7 @@ class LocalWorkspace extends EventEmitter {
|
||||
const invocationFields = ['phase', 'slotId', 'operationId', 'snapshotHash']
|
||||
const invocationFieldCount = invocationFields.filter(field => Object.hasOwn(input, field)).length
|
||||
if (invocationFieldCount > 0
|
||||
&& (input.resumeKind !== 'agent_slot'
|
||||
&& (!['agent_slot', 'v4_task_decision'].includes(input.resumeKind)
|
||||
|| invocationFieldCount !== invocationFields.length)) {
|
||||
throw new Error('LOCAL_RUN_CONTINUATION_INVALID')
|
||||
}
|
||||
@@ -1110,6 +1141,103 @@ class LocalWorkspace extends EventEmitter {
|
||||
return { ...match, state: continuation.state }
|
||||
}
|
||||
|
||||
v4TaskDecisionSource(durable, gate, request) {
|
||||
if (durable?.mode !== 'auto' || durable.orchestration?.version !== 4
|
||||
|| gate.type !== 'input' || gate.runId !== durable.runId
|
||||
|| !request || Object.keys(request).length !== 7
|
||||
|| request.source !== 'task-decision' || request.phase !== 'discussion'
|
||||
|| request.snapshotHash !== durable.orchestration.snapshotHash) return null
|
||||
const sources = durable.agentRuns.filter(attempt => (
|
||||
attempt.agentRunId === gate.agentRunId && attempt.kind === gate.agentKind
|
||||
&& attempt.status === 'completed' && attempt.round === request.round
|
||||
&& attempt.context?.operationId === request.operationId
|
||||
&& attempt.context?.snapshotHash === request.snapshotHash
|
||||
&& attempt.context?.taskDecision?.status === 'needs-human'
|
||||
&& hashValue(attempt.context.taskDecision) === request.decisionHash
|
||||
))
|
||||
if (sources.length !== 1 || request.operationId !== this.autoRunner.v4OperationId(
|
||||
durable, gate.agentKind, `discussion:${request.round}`, request.slotId, request.round,
|
||||
)) return null
|
||||
return sources[0]
|
||||
}
|
||||
|
||||
canResumeV4TaskDecision(durable, gate, request) {
|
||||
const source = this.v4TaskDecisionSource(durable, gate, request)
|
||||
const continuation = durable.continuation
|
||||
const cursor = durable.orchestration
|
||||
const group = this.state.groups.find(item => item.id === durable.groupId)
|
||||
const slot = cursor?.slots.find(item => item.slotId === request?.slotId)
|
||||
return Boolean(source && group && gate.options.length === 2
|
||||
&& gate.options[0].optionId === 'respond' && gate.options[0].kind === 'respond'
|
||||
&& gate.options[1].optionId === 'cancel' && gate.options[1].kind === 'reject'
|
||||
&& continuation?.resumeKind === 'v4_task_decision'
|
||||
&& cursor.phase === 'discussion' && cursor.round === request.round
|
||||
&& durable.currentRound === request.round && continuation.round === request.round
|
||||
&& continuation.slotId === request.slotId
|
||||
&& continuation.operationId === request.operationId
|
||||
&& continuation.snapshotHash === request.snapshotHash
|
||||
&& slot?.agentRunId === source.agentRunId && slot.agentKind === source.kind
|
||||
&& slot.operationId === request.operationId && slot.commitStatus === 'committed'
|
||||
&& this.autoRunner.v4NaturalOwner(group, durable, durable.threadRootId) === source.kind)
|
||||
}
|
||||
|
||||
v4TaskDecisionResponses(runId) {
|
||||
const durable = this.runLedger?.get?.(runId)
|
||||
if (!durable) return []
|
||||
return this.humanGateStore.list({ runId }).flatMap(item => {
|
||||
if (item.type !== 'input' || item.status !== 'approved') return []
|
||||
try {
|
||||
const gate = this.humanGateStore.get(item.gateId)
|
||||
const request = this.humanGateStore.request(item.gateId)
|
||||
const source = this.v4TaskDecisionSource(durable, gate, request)
|
||||
return source && gate.decision?.response ? [{
|
||||
id: gate.gateId, round: source.round, agentKind: 'human',
|
||||
text: gate.decision.response,
|
||||
}] : []
|
||||
} catch { return [] }
|
||||
})
|
||||
}
|
||||
|
||||
async requestV4TaskDecision(group, controller, message) {
|
||||
const durable = this.runLedger?.get?.(controller.runId)
|
||||
if (!durable) return null
|
||||
const source = durable.agentRuns.find(attempt => (
|
||||
attempt.agentRunId === message.trace?.agentRunId && attempt.kind === message.agentKind
|
||||
))
|
||||
const slot = durable.orchestration?.slots.find(item => item.agentRunId === source?.agentRunId)
|
||||
if (!source || !slot || source.context?.taskDecision?.status !== 'needs-human'
|
||||
|| slot.commitStatus !== 'committed') throw new Error('LOCAL_RUN_CONTINUATION_INVALID')
|
||||
const request = {
|
||||
source: 'task-decision', phase: 'discussion', round: source.round,
|
||||
slotId: slot.slotId, operationId: slot.operationId,
|
||||
snapshotHash: durable.orchestration.snapshotHash,
|
||||
decisionHash: hashValue(source.context.taskDecision),
|
||||
}
|
||||
const decision = await this.requestHumanGate({
|
||||
type: 'input', runId: durable.runId, agentRunId: source.agentRunId, agentKind: source.kind,
|
||||
createdAt: new Date(slot.finishedAt).toISOString(),
|
||||
summary: source.context.taskDecision.reason,
|
||||
options: [
|
||||
{ optionId: 'respond', name: 'Respond', kind: 'respond' },
|
||||
{ optionId: 'cancel', name: 'Cancel', kind: 'reject' },
|
||||
],
|
||||
request,
|
||||
}, {
|
||||
signal: controller.signal,
|
||||
preserveOnAbort: () => controller.stopReason === 'shutdown',
|
||||
continuation: {
|
||||
resumeKind: 'v4_task_decision', agentRunId: source.agentRunId, agentKind: source.kind,
|
||||
round: source.round, phase: 'discussion', slotId: slot.slotId,
|
||||
operationId: slot.operationId, snapshotHash: durable.orchestration.snapshotHash,
|
||||
},
|
||||
})
|
||||
if (this.completeHumanGateContinuation(durable.runId, decision.gateId,
|
||||
decision.status === 'approved' ? 'completed' : 'cancelled') !== true) {
|
||||
throw new Error('LOCAL_RUN_PERSIST_FAILED')
|
||||
}
|
||||
return decision
|
||||
}
|
||||
|
||||
async requestHumanGate(input, options = {}) {
|
||||
const runId = String(input?.runId || '')
|
||||
const previous = this.humanGateWaitTails.get(runId) || Promise.resolve()
|
||||
@@ -1217,6 +1345,11 @@ class LocalWorkspace extends EventEmitter {
|
||||
if (continuation.state !== 'pending' && gate.status === 'pending') return false
|
||||
try {
|
||||
const request = this.humanGateStore.request(gate.gateId)
|
||||
if (continuation.resumeKind === 'v4_task_decision') {
|
||||
const pack = this.contextPackStore.get(runRecord.contextPackId)
|
||||
return pack.taskId === runRecord.taskId
|
||||
&& this.canResumeV4TaskDecision(runRecord, gate, request)
|
||||
}
|
||||
const connectorBound = gate.type === 'input' || request?.source === 'connector'
|
||||
if (connectorBound && (
|
||||
continuation.requestId !== request.requestId
|
||||
@@ -1437,7 +1570,7 @@ class LocalWorkspace extends EventEmitter {
|
||||
if (durable?.mode !== 'auto' || continuation?.resumeKind !== 'agent_slot'
|
||||
|| cursor?.version !== 4 || cursor.workflow !== 'auto'
|
||||
|| cursor.template !== 'discussion'
|
||||
|| !['proposal', 'challenge', 'work', 'synthesis', 'verification'].includes(phase)
|
||||
|| !['proposal', 'discussion', 'challenge', 'work', 'synthesis', 'verification'].includes(phase)
|
||||
|| !['budget', 'permission', 'retry', 'input'].includes(gate?.type)
|
||||
|| gate.runId !== durable.runId || gate.agentRunId !== continuation.agentRunId
|
||||
|| gate.agentKind !== continuation.agentKind
|
||||
@@ -1472,7 +1605,7 @@ class LocalWorkspace extends EventEmitter {
|
||||
|| (slot.resultRefs?.workflowOutcomeRefs || []).some(item => (
|
||||
item?.receipt?.phase === phase && item.receipt.operationId === slot.operationId
|
||||
))) return false
|
||||
let operationPhase = phase
|
||||
let operationPhase = phase === 'discussion' ? `discussion:${cursor.round}` : phase
|
||||
if (phase === 'work') {
|
||||
const matchingAssignments = cursor.coordinationPlan?.assignments?.filter(item => (
|
||||
item.ownerKind === slot.agentKind && item.taskId
|
||||
@@ -1510,6 +1643,7 @@ class LocalWorkspace extends EventEmitter {
|
||||
}
|
||||
return slot.operationId === this.autoRunner.v4OperationId(
|
||||
controller, slot.agentKind, operationPhase, slot.slotId,
|
||||
...(phase === 'discussion' ? [cursor.round] : []),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1728,6 +1862,11 @@ class LocalWorkspace extends EventEmitter {
|
||||
try {
|
||||
controller = this.runCoordinator.resume(durable)
|
||||
const group = this.getGroup(durable.groupId)
|
||||
if (durable.continuation.resumeKind === 'v4_task_decision') {
|
||||
const finalStatus = await this.autoRunner.resume(group, durable, controller)
|
||||
await this.finishRun(durable.groupId, controller, finalStatus)
|
||||
return
|
||||
}
|
||||
const resumableManual = this.canResumeManualOrchestration(durable)
|
||||
const resumableAuto = this.canResumeAutoOrchestration(durable)
|
||||
const resumableV4ManualSlot = this.canResumeV4ManualAgentSlot(durable, gate)
|
||||
@@ -2375,26 +2514,47 @@ class LocalWorkspace extends EventEmitter {
|
||||
}
|
||||
|
||||
persistSessionState(key, sessionRef, meta) {
|
||||
const canonicalKey = this.canonicalConversationSessionKey(key)
|
||||
const nextRef = normalizeSessionRef(sessionRef)
|
||||
if (!SESSION_KEY.test(String(key || '')) || !nextRef) return false
|
||||
const hadSession = Object.hasOwn(this.state.sessions, key)
|
||||
const hadMeta = Object.hasOwn(this.state.sessionMeta, key)
|
||||
const previousSession = this.state.sessions[key]
|
||||
const previousMeta = this.state.sessionMeta[key]
|
||||
this.state.sessions[key] = nextRef
|
||||
this.state.sessionMeta[key] = normalizeSessionMeta(meta)
|
||||
if (!SESSION_KEY.test(String(canonicalKey || '')) || !nextRef) return false
|
||||
const hadSession = Object.hasOwn(this.state.sessions, canonicalKey)
|
||||
const hadMeta = Object.hasOwn(this.state.sessionMeta, canonicalKey)
|
||||
const hadLegacySession = Object.hasOwn(this.state.sessions, key)
|
||||
const hadLegacyMeta = Object.hasOwn(this.state.sessionMeta, key)
|
||||
const previousLegacySession = this.state.sessions[key]
|
||||
const previousLegacyMeta = this.state.sessionMeta[key]
|
||||
const previousSession = this.state.sessions[canonicalKey]
|
||||
const previousMeta = this.state.sessionMeta[canonicalKey]
|
||||
this.state.sessions[canonicalKey] = nextRef
|
||||
this.state.sessionMeta[canonicalKey] = normalizeSessionMeta(meta)
|
||||
if (canonicalKey !== key) {
|
||||
delete this.state.sessions[key]
|
||||
delete this.state.sessionMeta[key]
|
||||
}
|
||||
try {
|
||||
this.save()
|
||||
} catch (error) {
|
||||
if (hadSession) this.state.sessions[key] = previousSession
|
||||
else delete this.state.sessions[key]
|
||||
if (hadMeta) this.state.sessionMeta[key] = previousMeta
|
||||
else delete this.state.sessionMeta[key]
|
||||
if (hadSession) this.state.sessions[canonicalKey] = previousSession
|
||||
else delete this.state.sessions[canonicalKey]
|
||||
if (hadMeta) this.state.sessionMeta[canonicalKey] = previousMeta
|
||||
else delete this.state.sessionMeta[canonicalKey]
|
||||
if (canonicalKey !== key) {
|
||||
if (hadLegacySession) this.state.sessions[key] = previousLegacySession
|
||||
else delete this.state.sessions[key]
|
||||
if (hadLegacyMeta) this.state.sessionMeta[key] = previousLegacyMeta
|
||||
else delete this.state.sessionMeta[key]
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
canonicalConversationSessionKey(key) {
|
||||
const value = String(key || '')
|
||||
const match = value.match(/^([^:]+):task:[^:]+:([^:]+)$/)
|
||||
return match ? `${match[1]}:${match[2]}` : value
|
||||
}
|
||||
|
||||
persistSessionRef(key, sessionRef) {
|
||||
const next = normalizeSessionRef(sessionRef)
|
||||
if (!SESSION_KEY.test(String(key || '')) || !next || next === this.state.sessions[key]) return false
|
||||
|
||||
@@ -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,31 @@ 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.',
|
||||
), '')
|
||||
})
|
||||
|
||||
test('successful explanations of authentication errors remain successful', () => {
|
||||
for (const text of [
|
||||
'HTTP 401: Unauthorized means the request needs valid credentials.',
|
||||
'HTTP 403: Forbidden is the expected response in this test.',
|
||||
'HTTP 401: Invalid token can be reproduced with the following test.',
|
||||
]) {
|
||||
assert.equal(requireTerminalAgentResult({ outcome: 'completed', text }).text, text)
|
||||
}
|
||||
})
|
||||
@@ -19,6 +19,7 @@ const {
|
||||
parseMimoOutput,
|
||||
parseOpenCodeOutput,
|
||||
parseOpenCodeReviewOutput,
|
||||
parsePiOutput,
|
||||
parseWorkBuddyOutput,
|
||||
prepareCommand,
|
||||
readHermesFinalResponse,
|
||||
@@ -1032,6 +1033,26 @@ test('Claude uses partial stream JSON and resumes its native session', () => {
|
||||
assert.equal(spec.promptArg, true)
|
||||
})
|
||||
|
||||
test('Claude selected Provider overrides native cloud selection without credentials in argv', () => {
|
||||
const spec = invocation('claude', '/tmp/claude', '/tmp/work', 'native-session', {
|
||||
provider: { id: 'anthropic', model: 'selected-model', baseUrl: 'https://selected.example' },
|
||||
env: { ANTHROPIC_API_KEY: 'selected-secret-key' },
|
||||
})
|
||||
const settings = JSON.parse(spec.args[spec.args.indexOf('--settings') + 1])
|
||||
assert.equal(settings.env.ANTHROPIC_BASE_URL, 'https://selected.example')
|
||||
assert.equal(settings.env.ANTHROPIC_API_KEY, '')
|
||||
assert.equal(settings.env.ANTHROPIC_AUTH_TOKEN, '')
|
||||
assert.equal(settings.env.CLAUDE_CODE_OAUTH_TOKEN, '')
|
||||
assert.equal(settings.env.ANTHROPIC_CUSTOM_HEADERS, '')
|
||||
assert.match(settings.apiKeyHelper, /MELDWORK_PROVIDER_API_KEY/)
|
||||
for (const flag of ['BEDROCK', 'VERTEX', 'FOUNDRY', 'MANTLE']) {
|
||||
assert.equal(settings.env[`CLAUDE_CODE_USE_${flag}`], '0')
|
||||
}
|
||||
assert.equal(spec.args[spec.args.indexOf('--model') + 1], 'selected-model')
|
||||
assert.equal(spec.args[spec.args.indexOf('--resume') + 1], 'native-session')
|
||||
assert.equal(JSON.stringify(spec.args).includes('selected-secret-key'), false)
|
||||
})
|
||||
|
||||
test('MiMo uses ACP by default and declares its JSON setup fallback', () => {
|
||||
const spec = invocation('mimo', '/tmp/mimo', '/tmp/work', 'mimo-session', {
|
||||
sandbox: 'workspace-write',
|
||||
@@ -1062,6 +1083,110 @@ 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', '--tools', 'read,grep,find,ls', '--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('Pi ignores user/tool echoes and tool ids while preserving the assistant answer and native session', () => {
|
||||
const raw = [
|
||||
{ type: 'session', id: 'pi-persistent-session' },
|
||||
{ type: 'message_end', message: { role: 'user', content: [{ type: 'text', text: 'Private input' }] } },
|
||||
{ type: 'tool_execution_end', id: 'unrelated-tool-id' },
|
||||
{ type: 'message_end', message: { role: 'toolResult', content: [{ type: 'text', text: 'Tool noise' }] } },
|
||||
{ type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'Actual answer' } },
|
||||
{ type: 'turn_end', message: { role: 'assistant', stopReason: 'stop' } },
|
||||
{ type: 'agent_end', messages: [{ role: 'assistant', stopReason: 'stop' }] },
|
||||
{ type: 'agent_settled' },
|
||||
].map(event => JSON.stringify(event)).join('\n')
|
||||
assert.deepEqual(parsePiOutput(raw), { text: 'Actual answer', sessionRef: 'pi-persistent-session' })
|
||||
assert.deepEqual(profileOutput('pi', raw), {
|
||||
text: 'Actual answer', sessionRef: 'pi-persistent-session', outcome: 'completed',
|
||||
})
|
||||
})
|
||||
|
||||
test('Pi agent_end preserves failed, cancelled and incomplete assistant outcomes', () => {
|
||||
for (const [stopReason, outcome] of [['error', 'failed'], ['aborted', 'cancelled'], ['length', 'partial']]) {
|
||||
const raw = [
|
||||
{ type: 'session', id: 'pi-terminal-session' },
|
||||
{ type: 'message_end', message: { role: 'user', content: [{ type: 'text', text: 'Do not echo me' }] } },
|
||||
{ type: 'turn_end', message: { role: 'assistant', stopReason } },
|
||||
{ type: 'agent_end', messages: [{ role: 'assistant', stopReason }] },
|
||||
{ type: 'agent_settled' },
|
||||
].map(event => JSON.stringify(event)).join('\n')
|
||||
const result = profileOutput('pi', raw)
|
||||
assert.equal(result.outcome, outcome, stopReason)
|
||||
assert.equal(result.text, '', stopReason)
|
||||
assert.equal(parsePiOutput(raw).text, '', stopReason)
|
||||
}
|
||||
})
|
||||
|
||||
test('Pi clears a recoverable attempt failure after its internal retry completes', () => {
|
||||
const raw = [
|
||||
{ type: 'session', id: 'pi-retry-session' },
|
||||
{ type: 'turn_end', message: { role: 'assistant', stopReason: 'error' } },
|
||||
{ type: 'agent_end', messages: [{ role: 'assistant', stopReason: 'error' }], willRetry: true },
|
||||
{ type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'Recovered answer' } },
|
||||
{ type: 'turn_end', message: { role: 'assistant', stopReason: 'stop' } },
|
||||
{ type: 'agent_end', messages: [{ role: 'assistant', stopReason: 'stop' }], willRetry: false },
|
||||
{ type: 'agent_settled' },
|
||||
].map(event => JSON.stringify(event)).join('\n')
|
||||
assert.deepEqual(profileOutput('pi', raw), {
|
||||
text: 'Recovered answer', sessionRef: 'pi-retry-session', 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 +1727,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({
|
||||
@@ -1724,6 +1856,104 @@ process.stdout.write(JSON.stringify({ type: 'agent_end' }) + '\\n')
|
||||
assert.equal(result.outcome, 'completed')
|
||||
})
|
||||
|
||||
test('runAgent strips a full long multi-agent prompt echo from Pi output', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-pi-group-echo-'))
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const prompt = [
|
||||
'You are participating in a local multi-agent discussion.',
|
||||
'',
|
||||
'Your role this turn is worker.',
|
||||
'',
|
||||
'The current collaboration phase is proposal.',
|
||||
'',
|
||||
'User task:',
|
||||
'Share a short proposal.',
|
||||
'',
|
||||
'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.',
|
||||
].join('\n')
|
||||
const cli = executable(directory, 'pi-group-prompt-echo.cjs', `
|
||||
const prompt = ${JSON.stringify(prompt)}
|
||||
process.stdout.write(JSON.stringify({ type: 'session', id: 'pi-group-echo-session' }) + '\\n')
|
||||
process.stdout.write(JSON.stringify({
|
||||
type: 'message_update',
|
||||
assistantMessageEvent: { type: 'text_delta', delta: prompt + '\\n\\nProposal: keep the shared task state explicit.' },
|
||||
}) + '\\n')
|
||||
process.stdout.write(JSON.stringify({ type: 'agent_end' }) + '\\n')
|
||||
`)
|
||||
const result = await runAgent(
|
||||
{ kind: 'pi', executable: cli, name: 'Pi Agent' },
|
||||
prompt,
|
||||
directory,
|
||||
)
|
||||
assert.equal(result.text, 'Proposal: keep the shared task state explicit.')
|
||||
assert.equal(result.outcome, 'completed')
|
||||
})
|
||||
|
||||
test('Pi keeps running after a tool turn until its agent loop ends', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-pi-tool-turn-'))
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const cli = executable(directory, 'pi-tool-turn.cjs', `
|
||||
const send = value => process.stdout.write(JSON.stringify(value) + '\\n')
|
||||
send({ type: 'session', id: 'pi-tool-turn-session' })
|
||||
send({ type: 'message_update', assistantMessageEvent: {
|
||||
type: 'text_delta', delta: 'Preparing evidence. ',
|
||||
} })
|
||||
send({ type: 'turn_end', message: { role: 'assistant', stopReason: 'toolUse' } })
|
||||
setTimeout(() => {
|
||||
send({ type: 'message_update', assistantMessageEvent: {
|
||||
type: 'text_delta', delta: 'Final answer.',
|
||||
} })
|
||||
send({ type: 'agent_end', messages: [{ role: 'assistant', stopReason: 'stop' }] })
|
||||
}, 400)
|
||||
`)
|
||||
|
||||
const result = await runAgent(
|
||||
{ kind: 'pi', executable: cli, name: 'Pi Agent' },
|
||||
'research the current market',
|
||||
directory,
|
||||
)
|
||||
assert.deepEqual(result, {
|
||||
text: 'Preparing evidence. Final answer.',
|
||||
sessionRef: 'pi-tool-turn-session',
|
||||
outcome: 'completed',
|
||||
})
|
||||
})
|
||||
|
||||
test('runAgent strips partial Pi protocol and Harness record echoes', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-pi-partial-echo-'))
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const prompt = 'Continue the current group discussion using the Harness context below.'
|
||||
const cli = executable(directory, 'pi-partial-prompt-echo.cjs', `
|
||||
process.stdout.write(JSON.stringify({ type: 'session', id: 'pi-partial-echo-session' }) + '\\n')
|
||||
process.stdout.write(JSON.stringify({
|
||||
type: 'message_update',
|
||||
assistantMessageEvent: { type: 'text_delta', delta: [
|
||||
'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.',
|
||||
'',
|
||||
'A useful conclusion.',
|
||||
'',
|
||||
'Selected records from completed phases:',
|
||||
'MELDWORK_V4_COLLABORATION_PACKAGE_V1',
|
||||
'[index] hermes',
|
||||
'Artifact: artifact-123',
|
||||
'[proposal] agent=claude Completed the proposal phase. Continue with the next step.',
|
||||
].join('\\n') },
|
||||
}) + '\\n')
|
||||
process.stdout.write(JSON.stringify({ type: 'agent_end' }) + '\\n')
|
||||
`)
|
||||
const result = await runAgent(
|
||||
{ kind: 'pi', executable: cli, name: 'Pi Agent' },
|
||||
prompt,
|
||||
directory,
|
||||
)
|
||||
assert.equal(result.text, 'A useful conclusion.\n\nContinue with the next step.')
|
||||
assert.equal(result.outcome, 'completed')
|
||||
})
|
||||
|
||||
test('recorded fixtures cover every supported built-in output schema', () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(
|
||||
path.join(OUTPUT_FIXTURE_DIRECTORY, 'manifest.json'),
|
||||
|
||||
@@ -5,6 +5,7 @@ const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const { EventEmitter } = require('node:events')
|
||||
const { PassThrough } = require('node:stream')
|
||||
const { probeAgentCapabilities } = require('../../../src/agents/cli/cli-discovery.cjs')
|
||||
const {
|
||||
detectAgents,
|
||||
imageAttachmentLimit,
|
||||
@@ -37,6 +38,93 @@ test('search path includes common user CLI locations', () => {
|
||||
assert.match(searchPath(), /\.mimocode\/bin/)
|
||||
})
|
||||
|
||||
test('capability timeouts retry once and remain inconclusive when both attempts time out', async () => {
|
||||
for (const recover of [true, false]) {
|
||||
const timeouts = []
|
||||
const result = await probeAgentCapabilities('pi', '/test/timeout-pi', {
|
||||
env: {},
|
||||
execFileFn: async (_command, _args, options) => {
|
||||
timeouts.push(options.timeout)
|
||||
if (!recover || timeouts.length === 1) {
|
||||
throw Object.assign(new Error('timeout'), { killed: true })
|
||||
}
|
||||
return { stdout: argsForProbe(_command, _args) }
|
||||
},
|
||||
})
|
||||
assert.deepEqual(timeouts, [8000, 16000])
|
||||
assert.equal(result.compatibilityState, recover ? 'compatible' : 'unknown')
|
||||
if (!recover) assert.equal(result.incompatibilityReason, 'LOCAL_AGENT_CAPABILITY_PROBE_TIMEOUT')
|
||||
}
|
||||
})
|
||||
|
||||
function argsForProbe(_command, args) {
|
||||
return args.includes('--mode')
|
||||
? '{"type":"session","id":"probe-session"}'
|
||||
: '--mode json --print --session --tools --approve --no-approve'
|
||||
}
|
||||
|
||||
test('only unchanged recently verified executables survive inconclusive capability probes', async () => {
|
||||
const executable = '/test/cached-workbuddy'
|
||||
let identity = 1
|
||||
let mode = 'success'
|
||||
const options = {
|
||||
env: {}, version: '2.115.0',
|
||||
statFn: () => ({ dev: 1, ino: identity, size: 100, mtimeMs: 1, ctimeMs: 1 }),
|
||||
execFileFn: async () => {
|
||||
if (mode === 'timeout') throw Object.assign(new Error('timeout'), { code: 'ETIMEDOUT' })
|
||||
return { stdout: mode === 'success'
|
||||
? '--print --output-format --permission-mode --max-turns --resume json plan acceptEdits'
|
||||
: 'different CLI help' }
|
||||
},
|
||||
}
|
||||
assert.equal((await probeAgentCapabilities('workbuddy', executable, options)).compatibilityState, 'compatible')
|
||||
mode = 'timeout'
|
||||
assert.equal((await probeAgentCapabilities('workbuddy', executable, options)).compatibilityState, 'compatible')
|
||||
identity = 2
|
||||
assert.equal((await probeAgentCapabilities('workbuddy', executable, options)).compatibilityState, 'unknown')
|
||||
identity = 1
|
||||
options.version = '2.132.0'
|
||||
assert.equal((await probeAgentCapabilities('workbuddy', executable, options)).compatibilityState, 'unknown')
|
||||
options.version = '2.115.0'
|
||||
mode = 'incompatible'
|
||||
assert.equal((await probeAgentCapabilities('workbuddy', executable, options)).compatibilityState, 'incompatible')
|
||||
mode = 'timeout'
|
||||
assert.equal((await probeAgentCapabilities('workbuddy', executable, options)).compatibilityState, 'unknown')
|
||||
})
|
||||
|
||||
test('Agent detection does not promote an inconclusive capability check to compatible', async () => {
|
||||
const [agent] = await detectAgents({
|
||||
env: {},
|
||||
resolveExecutableFn: async kind => kind === 'pi' ? '/test/cold-pi' : null,
|
||||
execFileFn: async (_command, args) => {
|
||||
if (args[0] === '--version') return { stdout: '0.84.2' }
|
||||
throw Object.assign(new Error('timeout'), { killed: true })
|
||||
},
|
||||
})
|
||||
assert.equal(agent.kind, 'pi')
|
||||
assert.equal(agent.compatibilityState, 'unknown')
|
||||
assert.equal(agent.incompatibilityReason, 'LOCAL_AGENT_CAPABILITY_PROBE_TIMEOUT')
|
||||
})
|
||||
|
||||
test('Pi detection rejects same-name programs and CLIs missing required permission controls', async () => {
|
||||
for (const [name, help, expected] of [
|
||||
['unrelated', 'Pi help: calculate digits of pi', 'incompatible'],
|
||||
['no-permission-controls', '--mode json --print --session --tools', 'incompatible'],
|
||||
['required-protocol', '--mode json --print --session --tools --approve --no-approve', 'compatible'],
|
||||
]) {
|
||||
const [agent] = await detectAgents({
|
||||
env: {},
|
||||
resolveExecutableFn: async kind => kind === 'pi' ? `/test/pi-${name}/pi` : null,
|
||||
execFileFn: async (_command, args) => ({ stdout: args[0] === '--version' ? '0.84.2' : help }),
|
||||
})
|
||||
assert.equal(agent.kind, 'pi')
|
||||
assert.equal(agent.compatibilityState, expected, name)
|
||||
if (expected === 'incompatible') {
|
||||
assert.equal(agent.incompatibilityReason, 'LOCAL_AGENT_REQUIRED_CAPABILITY_MISSING', name)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('Agent discovery scans independent Agent kinds concurrently', async () => {
|
||||
const started = []
|
||||
let release
|
||||
@@ -874,6 +962,9 @@ test('Agent detection passes only allowlisted system environment to version comm
|
||||
PATH: '/custom/bin',
|
||||
MELDWORK_PRIVATE_VALUE: 'desktop-private-value',
|
||||
OPENAI_API_KEY: 'provider-secret',
|
||||
https_proxy: 'http://proxy.example:3128',
|
||||
NO_PROXY: 'localhost,127.0.0.1',
|
||||
REQUESTS_CA_BUNDLE: '/certs/company.pem',
|
||||
},
|
||||
resolveExecutableFn: async kind => kind === 'kimi' ? '/tmp/kimi' : null,
|
||||
execFileFn: async (_command, args, options) => {
|
||||
@@ -897,10 +988,13 @@ test('Agent detection passes only allowlisted system environment to version comm
|
||||
assert.match(env.PATH, /\/custom\/bin/)
|
||||
assert.equal(env.MELDWORK_PRIVATE_VALUE, undefined)
|
||||
assert.equal(env.OPENAI_API_KEY, undefined)
|
||||
assert.equal(env.https_proxy, 'http://proxy.example:3128')
|
||||
assert.equal(env.NO_PROXY, 'localhost,127.0.0.1')
|
||||
assert.equal(env.REQUESTS_CA_BUNDLE, '/certs/company.pem')
|
||||
}
|
||||
})
|
||||
|
||||
test('Agent detection excludes executable shims that cannot report a version', async () => {
|
||||
test('Agent detection retains a broken installation with unavailable capabilities', async () => {
|
||||
const found = await detectAgents({
|
||||
platform: 'darwin',
|
||||
env: {},
|
||||
@@ -908,7 +1002,24 @@ test('Agent detection excludes executable shims that cannot report a version', a
|
||||
execFileFn: async () => { throw new Error('broken shim') },
|
||||
})
|
||||
|
||||
assert.deepEqual(found, [])
|
||||
assert.equal(found.length, 1)
|
||||
assert.equal(found[0].kind, 'codex')
|
||||
assert.equal(found[0].compatibilityState, 'incompatible')
|
||||
assert.equal(found[0].incompatibilityReason, 'LOCAL_AGENT_REQUIRED_CAPABILITY_MISSING')
|
||||
})
|
||||
|
||||
test('Agent detection uses capability evidence when the version command fails', async () => {
|
||||
const found = await detectAgents({
|
||||
env: { CODEX_HOME: '/tmp/custom-codex' },
|
||||
resolveExecutableFn: async kind => kind === 'codex' ? '/tmp/codex' : null,
|
||||
execFileFn: async (_command, args, options) => {
|
||||
assert.equal(options.env.CODEX_HOME, '/tmp/custom-codex')
|
||||
if (args[0] === '--version') throw Object.assign(new Error('timeout'), { killed: true })
|
||||
return { stdout: '--json --sandbox --skip-git-repo-check --image read-only workspace-write' }
|
||||
},
|
||||
})
|
||||
assert.equal(found.length, 1)
|
||||
assert.equal(found[0].compatibilityState, 'compatible')
|
||||
})
|
||||
|
||||
test('runAgent hides executable paths from spawn failures', async () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ const {
|
||||
parseKimiOutput,
|
||||
parseMimoOutput,
|
||||
parseOpenCodeOutput,
|
||||
parsePiOutput,
|
||||
parseWorkBuddyOutput,
|
||||
prepareCommand,
|
||||
readHermesFinalResponse,
|
||||
@@ -1194,6 +1195,39 @@ input.on('line', (line) => {
|
||||
assert.equal(answerEvents.at(-1).replace, true)
|
||||
})
|
||||
|
||||
test('a reused ACP session that refuses before any output is recoverable as stale', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-acp-stale-refusal-'))
|
||||
const cli = executable(directory, 'acp-stale-refusal.cjs', `
|
||||
const readline = require('node:readline')
|
||||
const input = readline.createInterface({ input: process.stdin })
|
||||
const send = value => process.stdout.write(JSON.stringify(value) + '\\n')
|
||||
input.on('close', () => process.exit(0))
|
||||
input.on('line', line => {
|
||||
const message = JSON.parse(line)
|
||||
if (message.method === 'initialize') {
|
||||
send({ jsonrpc: '2.0', id: message.id, result: { protocolVersion: 1 } })
|
||||
} else if (message.method === 'session/resume') {
|
||||
send({ jsonrpc: '2.0', id: message.id, result: {} })
|
||||
} else if (message.method === 'session/set_mode') {
|
||||
send({ jsonrpc: '2.0', id: message.id, result: {} })
|
||||
} else if (message.method === 'session/prompt') {
|
||||
send({ jsonrpc: '2.0', id: message.id, result: { stopReason: 'refusal' } })
|
||||
}
|
||||
})
|
||||
`)
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
|
||||
await assert.rejects(
|
||||
runAgent(
|
||||
{ kind: 'hermes', executable: cli, name: 'Hermes' },
|
||||
'continue the group task',
|
||||
directory,
|
||||
{ sessionRef: 'stale-session', sessionTransport: 'acp', hermesAcpAvailable: true },
|
||||
),
|
||||
error => error.message === 'LOCAL_AGENT_SESSION_INVALID',
|
||||
)
|
||||
})
|
||||
|
||||
test('ACP terminal stop reasons preserve exact outcome and failure semantics', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-acp-stop-reasons-'))
|
||||
const cli = executable(directory, 'acp-stop-reasons.cjs', `
|
||||
@@ -1546,6 +1580,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', '--tools', 'read,grep,find,ls', '--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')
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const http = require('node:http')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const { execFile } = require('node:child_process')
|
||||
const { promisify } = require('node:util')
|
||||
const test = require('node:test')
|
||||
const { invocation, runAgent } = require('../../../src/agents/cli/cli-adapters.cjs')
|
||||
const { providerOptionsFor } = require('../../../src/providers/provider-options.cjs')
|
||||
|
||||
test('Claude credential helper reads a secret as data without shell expansion', {
|
||||
skip: process.platform === 'win32',
|
||||
}, async () => {
|
||||
const secret = 'test-$(exit 37)-`exit 38`-"quoted"-$HOME-&-secret'
|
||||
const spec = invocation('claude', '/tmp/claude', '/tmp', '', {
|
||||
provider: { id: 'anthropic', model: 'test-model', baseUrl: 'https://example.com' },
|
||||
platform: 'linux',
|
||||
})
|
||||
const settings = JSON.parse(spec.args[spec.args.indexOf('--settings') + 1])
|
||||
const result = await promisify(execFile)('/bin/sh', ['-c', settings.apiKeyHelper], {
|
||||
env: { MELDWORK_PROVIDER_API_KEY: secret }, timeout: 5000,
|
||||
})
|
||||
assert.equal(result.stdout, secret)
|
||||
assert.equal(JSON.stringify(spec.args).includes(secret), false)
|
||||
})
|
||||
|
||||
test('Claude Windows credential helper does not interpolate the key into cmd syntax', () => {
|
||||
const spec = invocation('claude', 'C:\\claude.exe', 'C:\\work', '', {
|
||||
provider: { id: 'anthropic', model: 'test-model' }, platform: 'win32',
|
||||
})
|
||||
const settings = JSON.parse(spec.args[spec.args.indexOf('--settings') + 1])
|
||||
assert.match(settings.apiKeyHelper, /GetEnvironmentVariable\('MELDWORK_PROVIDER_API_KEY'\)/)
|
||||
assert.doesNotMatch(settings.apiKeyHelper, /%MELDWORK_PROVIDER_API_KEY%/)
|
||||
})
|
||||
|
||||
test('native Claude uses the selected Provider despite conflicting native settings', {
|
||||
skip: !process.env.MELDWORK_TEST_CLAUDE_EXECUTABLE,
|
||||
timeout: 40000,
|
||||
}, async (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-claude-provider-'))
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }))
|
||||
const requests = []
|
||||
const selectedKey = 'selected-validation-key'
|
||||
async function endpoint(label) {
|
||||
const server = http.createServer((req, res) => {
|
||||
req.resume()
|
||||
if (req.method === 'HEAD') { res.writeHead(200); res.end(); return }
|
||||
requests.push({ label, selectedKey: req.headers['x-api-key'] === selectedKey
|
||||
|| req.headers.authorization === `Bearer ${selectedKey}` })
|
||||
res.writeHead(200, { 'Content-Type': 'text/event-stream' })
|
||||
for (const event of [
|
||||
{ type: 'message_start', message: {
|
||||
id: 'msg_local', type: 'message', role: 'assistant', model: 'claude-sonnet-4-6',
|
||||
content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 1, output_tokens: 0 },
|
||||
} },
|
||||
{ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } },
|
||||
{ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'LOCAL_PROVIDER_OK' } },
|
||||
{ type: 'content_block_stop', index: 0 },
|
||||
{ type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 1 } },
|
||||
{ type: 'message_stop' },
|
||||
]) res.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`)
|
||||
res.end()
|
||||
})
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
t.after(() => new Promise(resolve => { server.close(resolve); server.closeAllConnections() }))
|
||||
return `http://127.0.0.1:${server.address().port}`
|
||||
}
|
||||
const selected = await endpoint('selected')
|
||||
const native = await endpoint('native')
|
||||
const config = path.join(root, '.claude')
|
||||
fs.mkdirSync(config)
|
||||
const filename = path.join(config, 'settings.json')
|
||||
const settings = JSON.stringify({
|
||||
apiKeyHelper: 'exit 39', permissions: { deny: ['Bash(*)'] },
|
||||
env: {
|
||||
CLAUDE_CODE_USE_BEDROCK: '1', ANTHROPIC_BASE_URL: native,
|
||||
ANTHROPIC_API_KEY: 'native-validation-key', ANTHROPIC_AUTH_TOKEN: 'native-validation-token',
|
||||
ANTHROPIC_CUSTOM_HEADERS: 'Authorization: Bearer native-header-token',
|
||||
},
|
||||
})
|
||||
fs.writeFileSync(filename, settings)
|
||||
const options = providerOptionsFor('claude', {
|
||||
OPENAI_API_KEY: selectedKey, OPENAI_BASE_URL: selected, OPENAI_MODEL: 'claude-sonnet-4-6',
|
||||
})
|
||||
const events = []
|
||||
const result = await runAgent({ kind: 'claude', executable: process.env.MELDWORK_TEST_CLAUDE_EXECUTABLE },
|
||||
'Reply with OK. Do not use tools.', root, {
|
||||
...options, signal: AbortSignal.timeout(25000), onEvent: event => events.push(event),
|
||||
env: {
|
||||
...options.env, HOME: root, USERPROFILE: root, CLAUDE_CONFIG_DIR: config,
|
||||
XDG_CONFIG_HOME: root, XDG_DATA_HOME: root, XDG_STATE_HOME: root, XDG_CACHE_HOME: root,
|
||||
HTTP_PROXY: '', HTTPS_PROXY: '', ALL_PROXY: '', http_proxy: '', https_proxy: '', all_proxy: '',
|
||||
NO_PROXY: '*', no_proxy: '*', CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
|
||||
},
|
||||
})
|
||||
assert.equal(result.text, 'LOCAL_PROVIDER_OK')
|
||||
assert.ok(requests.length > 0)
|
||||
assert.ok(requests.every(request => request.label === 'selected' && request.selectedKey))
|
||||
assert.equal(fs.readFileSync(filename, 'utf8'), settings)
|
||||
assert.equal(JSON.stringify(events).includes(selectedKey), false)
|
||||
})
|
||||
@@ -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) => {
|
||||
@@ -623,6 +639,8 @@ input.on('line', (line) => {
|
||||
},
|
||||
})
|
||||
const events = []
|
||||
let receivedStream
|
||||
const streamReceived = new Promise(resolve => { receivedStream = resolve })
|
||||
let resolved = false
|
||||
resultPromise = adapters.runAgent(
|
||||
{ kind: 'openclaw', executable: cli, name: 'OpenClaw' },
|
||||
@@ -631,7 +649,10 @@ input.on('line', (line) => {
|
||||
{
|
||||
...runtime,
|
||||
sessionRef: stableSessionRef,
|
||||
onEvent: event => events.push(event),
|
||||
onEvent: event => {
|
||||
events.push(event)
|
||||
if (events.length === 5) receivedStream()
|
||||
},
|
||||
},
|
||||
).then((result) => {
|
||||
resolved = true
|
||||
@@ -639,6 +660,8 @@ input.on('line', (line) => {
|
||||
})
|
||||
|
||||
await within(readWhenReady(readyFile))
|
||||
// The child's file write does not guarantee the parent has consumed stdout.
|
||||
await within(streamReceived)
|
||||
assert.equal(resolved, false)
|
||||
assert.deepEqual(events.map(event => event.type), [
|
||||
'tool_start', 'tool_update', 'tool_result_summary',
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const { execFile } = require('node:child_process')
|
||||
const { promisify } = require('node:util')
|
||||
const { networkEnvironment } = require('../../../src/agents/cli/cli-network-environment.cjs')
|
||||
const { systemChildEnvironment } = require('../../../src/agents/cli/cli-discovery.cjs')
|
||||
const { childEnvironment } = require('../../../src/agents/cli/cli-process-support.cjs')
|
||||
const { resolveNativeShellEnvironment, resolveNativeCredentialState } = require('../../../src/agents/local-agent-readiness.cjs')
|
||||
const { redactChildSecrets, createRuntimeEventEmitter } = require('../../../src/agents/cli/cli-runtime-event-sanitizer.cjs')
|
||||
|
||||
test('network configuration preserves POSIX proxy case and explicit empty overrides without runtime injection', () => {
|
||||
const source = {
|
||||
HTTP_PROXY: 'http://upper.example:8080', http_proxy: 'http://lower.example:8081',
|
||||
HTTPS_PROXY: '', NO_PROXY: 'localhost,127.0.0.1,::1', no_proxy: '',
|
||||
NODE_EXTRA_CA_CERTS: '/certs/company.pem', REQUESTS_CA_BUNDLE: '/certs/python.pem',
|
||||
NODE_USE_ENV_PROXY: '1', NODE_OPTIONS: '--require=/untrusted.cjs',
|
||||
NODE_TLS_REJECT_UNAUTHORIZED: '0', OPENAI_API_KEY: 'unrelated-secret',
|
||||
}
|
||||
const expected = { ...source }
|
||||
for (const key of ['NODE_OPTIONS', 'NODE_TLS_REJECT_UNAUTHORIZED', 'OPENAI_API_KEY']) delete expected[key]
|
||||
assert.deepEqual(networkEnvironment(source, 'darwin'), expected)
|
||||
const child = systemChildEnvironment(source, 'darwin')
|
||||
for (const [key, value] of Object.entries(expected)) assert.equal(child[key], value)
|
||||
assert.equal(child.NODE_OPTIONS, undefined)
|
||||
assert.equal(child.NODE_TLS_REJECT_UNAUTHORIZED, undefined)
|
||||
assert.equal(child.OPENAI_API_KEY, undefined)
|
||||
assert.deepEqual(networkEnvironment({ Https_Proxy: 'http://windows.example:8080' }, 'win32'), {
|
||||
HTTPS_PROXY: 'http://windows.example:8080',
|
||||
})
|
||||
})
|
||||
|
||||
test('login shell proxy and CA settings survive credential probes and actual child construction', { skip: process.platform === 'win32' }, async () => {
|
||||
const source = {
|
||||
PATH: '/usr/bin:/bin', HTTPS_PROXY: 'http://inherited.example:8080',
|
||||
http_proxy: 'http://shell.example:3128', NO_PROXY: 'localhost,127.0.0.1',
|
||||
NODE_EXTRA_CA_CERTS: '/certs/trusted.pem',
|
||||
}
|
||||
const shell = await resolveNativeShellEnvironment({
|
||||
cache: false, platform: 'linux', shell: '/bin/sh', home: '/tmp/meldwork-network-test', env: source,
|
||||
execFileFn: async (_file, args, options) => {
|
||||
assert.equal(options.env.http_proxy, source.http_proxy)
|
||||
return promisify(execFile)('/bin/sh', ['-c', `HTTPS_PROXY=''; export HTTPS_PROXY\n${args[1]}`], {
|
||||
env: { ...options.env, NODE_OPTIONS: 'must-not-be-returned' },
|
||||
})
|
||||
},
|
||||
})
|
||||
assert.equal(shell.env.HTTPS_PROXY, '')
|
||||
assert.equal(shell.env.NODE_OPTIONS, undefined)
|
||||
for (const key of ['http_proxy', 'NO_PROXY', 'NODE_EXTRA_CA_CERTS']) assert.equal(shell.env[key], source[key])
|
||||
let probed = false
|
||||
await resolveNativeCredentialState('claude', {
|
||||
executable: '/tmp/claude-network-test', home: '/tmp/meldwork-network-test', env: shell.env,
|
||||
execFileFn: async (_file, _args, options) => {
|
||||
probed = true
|
||||
assert.equal(options.env.http_proxy, source.http_proxy)
|
||||
assert.equal(options.env.HTTPS_PROXY, '')
|
||||
return { stdout: '{"loggedIn":true}', stderr: '' }
|
||||
},
|
||||
})
|
||||
assert.equal(probed, true)
|
||||
const env = childEnvironment({ kind: 'claude' }, '/tmp/workspace', {
|
||||
env: networkEnvironment(shell.env), sandbox: 'read-only',
|
||||
}, 'linux')
|
||||
assert.equal(env.http_proxy, source.http_proxy)
|
||||
assert.equal(env.HTTPS_PROXY, '')
|
||||
assert.equal(env.NODE_EXTRA_CA_CERTS, source.NODE_EXTRA_CA_CERTS)
|
||||
})
|
||||
|
||||
test('proxy credentials are redacted in diagnostics and across every streamed split', () => {
|
||||
const proxy = 'http://proxy-user:proxy%[email protected]:3128'
|
||||
const env = { https_proxy: proxy }
|
||||
const values = [proxy, 'proxy-user:proxy%40password', 'proxy-user:proxy@password',
|
||||
'proxy%40password', 'proxy@password', Buffer.from('proxy-user:proxy@password').toString('base64')]
|
||||
for (const value of values) {
|
||||
assert.equal(redactChildSecrets(`Connection failed: ${value}`, env), 'Connection failed: [redacted]')
|
||||
for (let split = 1; split < value.length; split += 1) {
|
||||
const emitted = []
|
||||
const events = createRuntimeEventEmitter({ onEvent: event => emitted.push(event) }, env)
|
||||
events.emit({ type: 'answer_delta', status: 'running', delta: `Connection failed: ${value.slice(0, split)}` })
|
||||
events.emit({ type: 'answer_delta', status: 'running', delta: `${value.slice(split)}. Retry later.` })
|
||||
events.emitFinalAnswer(`Connection failed: ${value}. Retry later.`)
|
||||
const delivered = emitted.filter(event => event.type === 'answer_delta').map(event => event.delta).join('')
|
||||
assert.equal(delivered.includes(value), false, `split ${split}`)
|
||||
assert.match(delivered, /\[redacted\]/)
|
||||
}
|
||||
}
|
||||
assert.equal(redactChildSecrets('https://public.example', { HTTPS_PROXY: 'https://public.example' }), 'https://public.example')
|
||||
assert.equal(redactChildSecrets('Bad proxy: user:private-password@', { HTTP_PROXY: 'user:private-password@' }), 'Bad proxy: [redacted]')
|
||||
})
|
||||
@@ -4,9 +4,49 @@ const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
|
||||
const { childEnvironment } = require('../../../src/agents/cli/cli-process-support.cjs')
|
||||
const { childEnvironment, failedAgentProcessError } = require('../../../src/agents/cli/cli-process-support.cjs')
|
||||
const { managedOpenClawOptions } = require('../../../src/agents/cli/openclaw-runtime.cjs')
|
||||
|
||||
test('local Agent child environments keep supported runtime settings but reject process injection', () => {
|
||||
const env = childEnvironment(
|
||||
{ kind: 'pi' }, '/tmp/workspace', {
|
||||
env: {
|
||||
OPENAI_BASE_URL: 'https://provider.example/v1',
|
||||
HTTPS_PROXY: 'http://proxy.example:3128',
|
||||
GITHUB_TOKEN: 'unrelated-secret',
|
||||
NODE_OPTIONS: '--require=/tmp/injected.js',
|
||||
DYLD_INSERT_LIBRARIES: '/tmp/injected.dylib',
|
||||
},
|
||||
}, 'darwin',
|
||||
)
|
||||
assert.equal(env.OPENAI_BASE_URL, 'https://provider.example/v1')
|
||||
assert.equal(env.HTTPS_PROXY, 'http://proxy.example:3128')
|
||||
assert.equal(Object.hasOwn(env, 'GITHUB_TOKEN'), false)
|
||||
assert.equal(Object.hasOwn(env, 'NODE_OPTIONS'), false)
|
||||
assert.equal(Object.hasOwn(env, 'DYLD_INSERT_LIBRARIES'), false)
|
||||
})
|
||||
|
||||
test('process diagnostics require explicit authentication failure evidence', () => {
|
||||
for (const diagnostic of [
|
||||
'EACCES: permission denied opening auth-service.ts',
|
||||
'Cannot find module credential-parser',
|
||||
'Model configuration failed: connection timed out',
|
||||
'Failed to download documentation: HTTP 403 Forbidden',
|
||||
]) {
|
||||
assert.equal(failedAgentProcessError(diagnostic).code, 'LOCAL_AGENT_PROCESS_FAILED')
|
||||
}
|
||||
for (const diagnostic of [
|
||||
'Invalid API key',
|
||||
'Authentication failed',
|
||||
'Please log in before continuing',
|
||||
'Select an auth type first.',
|
||||
'HTTP 401: Unauthorized',
|
||||
'认证失败,请重新登录',
|
||||
]) {
|
||||
assert.equal(failedAgentProcessError(diagnostic).code, 'LOCAL_AGENT_AUTH_REQUIRED')
|
||||
}
|
||||
})
|
||||
|
||||
test('OpenClaw child environment keeps only its guarded runtime and selected credential', (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-openclaw-child-env-'))
|
||||
const workdir = path.join(directory, 'workspace')
|
||||
@@ -46,6 +86,9 @@ test('OpenClaw child environment keeps only its guarded runtime and selected cre
|
||||
...runtime,
|
||||
env: {
|
||||
...runtime.env,
|
||||
https_proxy: 'http://proxy.example:3128',
|
||||
NO_PROXY: 'localhost,127.0.0.1,::1',
|
||||
NODE_EXTRA_CA_CERTS: '/certs/company.pem',
|
||||
MELDWORK_OPENCLAW_NATIVE_API_KEY: 'wrong-openclaw-key',
|
||||
GITHUB_TOKEN: 'caller-secret',
|
||||
ANTHROPIC_API_KEY: 'caller-anthropic-secret',
|
||||
@@ -56,6 +99,9 @@ test('OpenClaw child environment keeps only its guarded runtime and selected cre
|
||||
)
|
||||
|
||||
assert.equal(env.MELDWORK_OPENCLAW_API_KEY, 'selected-openclaw-key')
|
||||
assert.equal(env.https_proxy, 'http://proxy.example:3128')
|
||||
assert.equal(env.NO_PROXY, 'localhost,127.0.0.1,::1')
|
||||
assert.equal(env.NODE_EXTRA_CA_CERTS, '/certs/company.pem')
|
||||
assert.equal(env.OPENCLAW_GATEWAY_TOKEN, runtime.env.OPENCLAW_GATEWAY_TOKEN)
|
||||
assert.equal(env.OPENCLAW_DISABLE_BONJOUR, '1')
|
||||
assert.equal(Object.hasOwn(env, 'MELDWORK_OPENCLAW_NATIVE_API_KEY'), false)
|
||||
|
||||
@@ -527,6 +527,22 @@ test('terminal reconciliation never reconstructs an exact 8-byte child secret',
|
||||
}
|
||||
})
|
||||
|
||||
test('AWS access keys are redacted across every streaming boundary', () => {
|
||||
const secret = 'AKIATESTACCESSKEY1234'
|
||||
for (let split = 1; split < secret.length; split += 1) {
|
||||
const emitted = []
|
||||
const runtimeEvents = createRuntimeEventEmitter(
|
||||
{ onEvent: event => emitted.push(event) }, { AWS_ACCESS_KEY_ID: secret },
|
||||
)
|
||||
runtimeEvents.emit({ type: 'answer_delta', status: 'running', delta: secret.slice(0, split) })
|
||||
runtimeEvents.emit({ type: 'answer_delta', status: 'running', delta: secret.slice(split) })
|
||||
runtimeEvents.emitFinalAnswer(secret)
|
||||
assert.equal(JSON.stringify(emitted).includes(secret), false)
|
||||
assert.equal(emitted.filter(event => event.type === 'answer_delta')
|
||||
.map(event => event.delta).join('').includes(secret), false)
|
||||
}
|
||||
})
|
||||
|
||||
test('runtime answer delivery reconciles empty and incomplete deltas with the terminal answer', () => {
|
||||
for (const deltas of [[], [''], ['authoritative ']]) {
|
||||
const emitted = []
|
||||
|
||||
@@ -9,6 +9,7 @@ const {
|
||||
configureOpenClawGatewayRuntime,
|
||||
managedOpenClawOptions,
|
||||
nativeOpenClawOptions,
|
||||
refreshOpenClawRuntimeAfterStartup,
|
||||
validateOpenClawRuntimeGuard,
|
||||
} = require('../../../src/agents/cli/openclaw-runtime.cjs')
|
||||
const {
|
||||
@@ -129,6 +130,82 @@ test('Gateway setup rewrites the guarded config without persisting credentials',
|
||||
assert.equal(configText.includes(runtime.env.OPENCLAW_GATEWAY_TOKEN), false)
|
||||
})
|
||||
|
||||
test('startup migration refreshes the guard without changing execution policy', (t) => {
|
||||
const { directory, workdir } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const options = configureOpenClawGatewayRuntime(managedOpenClawOptions({
|
||||
storageRoot: directory, workdir, provider: provider(),
|
||||
}), 43123)
|
||||
const configPath = options.env.OPENCLAW_CONFIG_PATH
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'))
|
||||
config.agents.entries = { main: {} }
|
||||
config.discovery.wideArea = {}
|
||||
config.meta = { migrations: { modelPolicyAllowlist: true }, lastTouchedVersion: '2026.9.1' }
|
||||
fs.writeFileSync(`${configPath}.migrated`, JSON.stringify(config), { mode: 0o600 })
|
||||
fs.renameSync(`${configPath}.migrated`, configPath)
|
||||
assert.throws(() => validateOpenClawRuntimeGuard(options.openClawRuntimeGuard, options.env))
|
||||
const migrated = refreshOpenClawRuntimeAfterStartup(options)
|
||||
assert.equal(validateOpenClawRuntimeGuard(migrated.openClawRuntimeGuard, migrated.env), true)
|
||||
config.tools.allow.push('exec')
|
||||
fs.writeFileSync(configPath, JSON.stringify(config))
|
||||
assert.throws(() => validateOpenClawRuntimeGuard(migrated.openClawRuntimeGuard, migrated.env))
|
||||
})
|
||||
|
||||
test('startup migration rejects changed policies, credentials, paths and unknown configuration', async (t) => {
|
||||
for (const [name, mutate] of Object.entries({
|
||||
tools: config => { config.tools.allow.push('exec') },
|
||||
workspace: config => { config.agents.defaults.workspace = os.homedir() },
|
||||
agentOverride: config => { config.agents.entries = { main: { tools: { allow: ['exec'] } } } },
|
||||
credentials: config => { config.gateway.auth.token = 'replacement-token' },
|
||||
provider: config => { config.models.providers['meldwork-desktop'].baseUrl = 'https://other.example' },
|
||||
bind: config => { config.gateway.bind = 'lan' },
|
||||
discovery: config => { config.discovery.wideArea.enabled = true },
|
||||
unknown: config => { config.plugins = { enabled: true } },
|
||||
metadata: config => { config.meta = { unrecognized: true } },
|
||||
})) {
|
||||
await t.test(name, () => {
|
||||
const { directory, workdir } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const options = configureOpenClawGatewayRuntime(managedOpenClawOptions({
|
||||
storageRoot: directory, workdir, provider: provider(),
|
||||
}), 43123)
|
||||
const configPath = options.env.OPENCLAW_CONFIG_PATH
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'))
|
||||
mutate(config)
|
||||
fs.writeFileSync(configPath, JSON.stringify(config))
|
||||
assert.throws(() => refreshOpenClawRuntimeAfterStartup(options), {
|
||||
message: 'OPENCLAW_RUNTIME_UNSAFE_PATH',
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test('startup migration rejects symlinks and broadened file permissions', (t) => {
|
||||
const { directory, workdir } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const options = managedOpenClawOptions({ storageRoot: directory, workdir, provider: provider() })
|
||||
const configPath = options.env.OPENCLAW_CONFIG_PATH
|
||||
fs.renameSync(configPath, `${configPath}.original`)
|
||||
fs.symlinkSync(`${configPath}.original`, configPath)
|
||||
assert.throws(() => refreshOpenClawRuntimeAfterStartup(options), { message: 'OPENCLAW_RUNTIME_UNSAFE_PATH' })
|
||||
fs.unlinkSync(configPath)
|
||||
fs.renameSync(`${configPath}.original`, configPath)
|
||||
if (process.platform !== 'win32') {
|
||||
fs.chmodSync(configPath, 0o644)
|
||||
assert.throws(() => refreshOpenClawRuntimeAfterStartup(options), { message: 'OPENCLAW_RUNTIME_UNSAFE_PATH' })
|
||||
}
|
||||
})
|
||||
|
||||
test('startup migration rejects an oversized config before parsing it', (t) => {
|
||||
const { directory, workdir } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const options = managedOpenClawOptions({ storageRoot: directory, workdir, provider: provider() })
|
||||
fs.appendFileSync(options.env.OPENCLAW_CONFIG_PATH, ' '.repeat(256 * 1024))
|
||||
assert.throws(() => refreshOpenClawRuntimeAfterStartup(options), {
|
||||
message: 'OPENCLAW_RUNTIME_UNSAFE_PATH',
|
||||
})
|
||||
})
|
||||
|
||||
test('Gateway lifecycle retries setup before callback and closes the isolated process', async (t) => {
|
||||
const { withOpenClawGateway } = require('../../../src/agents/cli/cli-openclaw-gateway.cjs')
|
||||
const { directory, workdir } = fixture()
|
||||
@@ -160,6 +237,13 @@ if (command !== 'run') process.exit(2)
|
||||
const attemptsFile = ${JSON.stringify(attemptsFile)}
|
||||
const attempts = fs.existsSync(attemptsFile) ? Number(fs.readFileSync(attemptsFile, 'utf8')) : 0
|
||||
fs.writeFileSync(attemptsFile, String(attempts + 1))
|
||||
const migratedConfigPath = process.env.OPENCLAW_CONFIG_PATH
|
||||
const migratedConfig = JSON.parse(fs.readFileSync(migratedConfigPath, 'utf8'))
|
||||
migratedConfig.agents.entries = { main: {} }
|
||||
migratedConfig.discovery.wideArea = {}
|
||||
migratedConfig.meta = { migrations: { modelPolicyAllowlist: true }, lastTouchedVersion: '2026.9.1' }
|
||||
fs.writeFileSync(migratedConfigPath + '.migrated', JSON.stringify(migratedConfig), { mode: 0o600 })
|
||||
fs.renameSync(migratedConfigPath + '.migrated', migratedConfigPath)
|
||||
if (attempts === 0) {
|
||||
process.stderr.write('listen EADDRINUSE: address already in use\\n')
|
||||
process.exit(1)
|
||||
|
||||
@@ -26,6 +26,36 @@ function shellLiteral(value) {
|
||||
return `'${String(value).replaceAll("'", "'\\''")}'`
|
||||
}
|
||||
|
||||
test('catalog preserves an inconclusive timeout without hiding the installed CLI', async () => {
|
||||
const installer = new AgentInstaller({
|
||||
detectAgents: async () => [{
|
||||
kind: 'codex', compatibilityState: 'unknown',
|
||||
incompatibilityReason: 'LOCAL_AGENT_CAPABILITY_PROBE_TIMEOUT',
|
||||
incompatibilityProbe: 'codex-exec',
|
||||
}],
|
||||
findCommand: async () => '/usr/bin/npm',
|
||||
})
|
||||
const agent = (await installer.catalog()).agents.find(agent => agent.kind === 'codex')
|
||||
assert.equal(agent.installed, true)
|
||||
assert.equal(agent.compatibilityState, 'unknown')
|
||||
assert.equal(agent.incompatibilityReason, 'LOCAL_AGENT_CAPABILITY_PROBE_TIMEOUT')
|
||||
assert.equal(agent.incompatibilityProbe, 'codex-exec')
|
||||
})
|
||||
|
||||
test('catalog exposes install versus update action and permits updating an older CLI', async () => {
|
||||
const service = installer({
|
||||
detectAgents: async () => [{ kind: 'pi', version: '0.80.0', compatibilityState: 'compatible' }],
|
||||
runProcess: async () => {},
|
||||
})
|
||||
const pi = (await service.catalog()).agents.find(agent => agent.kind === 'pi')
|
||||
assert.equal(pi.installed, true)
|
||||
assert.equal(pi.installAction, 'update')
|
||||
assert.equal(pi.targetVersion, '0.84.2')
|
||||
await service.start('pi')
|
||||
await service.waitForIdle()
|
||||
assert.equal(service.state().errorCode, 'INSTALL_AGENT_VERIFY_FAILED')
|
||||
})
|
||||
|
||||
async function readWhenReady(filename, timeoutMs = 2000) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
|
||||
@@ -12,6 +12,39 @@ const {
|
||||
resolveNativeShellEnvironment,
|
||||
} = require('../../src/agents/local-agent-readiness.cjs')
|
||||
|
||||
test('native environment cache observes credential and proxy changes without a PATH change', async () => {
|
||||
const base = { platform: 'win32', home: 'C:\\meldwork-cache-test', env: { PATH: 'C:\\bin',
|
||||
ANTHROPIC_API_KEY: 'synthetic-before', HTTPS_PROXY: 'http://before.example:8080' } }
|
||||
const before = await resolveNativeShellEnvironment(base)
|
||||
const after = await resolveNativeShellEnvironment({ ...base, env: { ...base.env,
|
||||
ANTHROPIC_API_KEY: 'synthetic-after', HTTPS_PROXY: '' } })
|
||||
assert.equal(before.env.ANTHROPIC_API_KEY, 'synthetic-before')
|
||||
assert.equal(after.env.ANTHROPIC_API_KEY, 'synthetic-after')
|
||||
assert.equal(after.env.HTTPS_PROXY, '')
|
||||
const removed = await resolveNativeShellEnvironment({ ...base, env: { PATH: base.env.PATH } })
|
||||
assert.equal(removed.env.ANTHROPIC_API_KEY, undefined)
|
||||
assert.equal(removed.env.HTTPS_PROXY, undefined)
|
||||
})
|
||||
|
||||
test('explicit refresh replaces cached native shell settings and subsequent calls share the new result', {
|
||||
skip: process.platform === 'win32',
|
||||
}, async (t) => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-shell-refresh-'))
|
||||
t.after(() => fs.rmSync(home, { recursive: true, force: true }))
|
||||
const shell = path.join(home, 'sh')
|
||||
const settings = path.join(home, 'native-env')
|
||||
fs.writeFileSync(shell, '#!/bin/sh\n. "$HOME/native-env"\nexec /bin/sh "$@"\n', { mode: 0o700 })
|
||||
fs.writeFileSync(settings, 'export ANTHROPIC_BASE_URL=https://before.example\n', { mode: 0o600 })
|
||||
const options = { home, shell, platform: 'linux', env: { PATH: '/usr/bin:/bin' } }
|
||||
const first = await resolveNativeShellEnvironment(options)
|
||||
assert.equal(first.env.ANTHROPIC_BASE_URL, 'https://before.example')
|
||||
fs.writeFileSync(settings, 'export ANTHROPIC_BASE_URL=https://after.example\n', { mode: 0o600 })
|
||||
assert.strictEqual(await resolveNativeShellEnvironment(options), first)
|
||||
const refreshed = await resolveNativeShellEnvironment({ ...options, refresh: true })
|
||||
assert.equal(refreshed.env.ANTHROPIC_BASE_URL, 'https://after.example')
|
||||
assert.strictEqual(await resolveNativeShellEnvironment(options), refreshed)
|
||||
})
|
||||
|
||||
function openClawModelsCatalog(apiKey, baseUrl = 'https://provider.example/v1') {
|
||||
return JSON.stringify({
|
||||
providers: {
|
||||
@@ -25,6 +58,49 @@ function openClawModelsCatalog(apiKey, baseUrl = 'https://provider.example/v1')
|
||||
})
|
||||
}
|
||||
|
||||
test('native readiness respects custom config roots and the Pi coding agent auth directory', () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-native-roots-'))
|
||||
const cases = [
|
||||
['pi', {}, path.join(home, '.pi', 'agent', 'auth.json')],
|
||||
['pi', { PI_CODING_AGENT_DIR: path.join(home, 'custom-pi') }, path.join(home, 'custom-pi', 'auth.json')],
|
||||
['codex', { CODEX_HOME: path.join(home, 'custom-codex') }, path.join(home, 'custom-codex', 'auth.json')],
|
||||
['claude', { CLAUDE_CONFIG_DIR: path.join(home, 'custom-claude') }, path.join(home, 'custom-claude', '.credentials.json')],
|
||||
['opencode', { XDG_DATA_HOME: path.join(home, 'data') }, path.join(home, 'data', 'opencode', 'auth.json')],
|
||||
]
|
||||
try {
|
||||
for (const [kind, env, filename] of cases) {
|
||||
fs.mkdirSync(path.dirname(filename), { recursive: true })
|
||||
fs.writeFileSync(filename, JSON.stringify({ provider: kind === 'pi'
|
||||
? { type: 'api_key', key: 'test-only-secret' }
|
||||
: { apiKey: 'test-only-secret' } }))
|
||||
assert.deepEqual(nativeCredentialState(kind, { home, env }), {
|
||||
state: 'ready', source: 'native-credential',
|
||||
})
|
||||
if (kind !== 'opencode') {
|
||||
assert.deepEqual(nativeCredentialEnvironment(kind, env), env)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('Pi native OAuth credentials use access and refresh fields', () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-pi-oauth-'))
|
||||
const directory = path.join(home, '.pi', 'agent')
|
||||
try {
|
||||
fs.mkdirSync(directory, { recursive: true })
|
||||
fs.writeFileSync(path.join(directory, 'auth.json'), JSON.stringify({
|
||||
anthropic: { type: 'oauth', access: 'test-access', refresh: 'test-refresh' },
|
||||
}))
|
||||
assert.deepEqual(nativeCredentialState('pi', { home, env: {} }), {
|
||||
state: 'ready', source: 'native-credential',
|
||||
})
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('Hermes readiness detects a native credential without returning its value', () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-hermes-readiness-'))
|
||||
const secret = 'hermes-secret-value'
|
||||
@@ -725,6 +801,157 @@ test('Claude auth status overrides a stale credential file', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('Claude probes native auth with cloud configuration even when a direct API key exists', async () => {
|
||||
let calls = 0
|
||||
const env = {
|
||||
ANTHROPIC_API_KEY: 'unused-direct-key', CLAUDE_CODE_USE_BEDROCK: '1',
|
||||
AWS_PROFILE: 'native-profile', AWS_REGION: 'us-east-1',
|
||||
AWS_SESSION_TOKEN: 'native-session-token', GITHUB_TOKEN: 'unrelated-secret',
|
||||
}
|
||||
const result = await resolveNativeCredentialState('claude', {
|
||||
env, executable: '/tmp/claude',
|
||||
execFileFn: async (command, args, options) => {
|
||||
calls += 1
|
||||
assert.deepEqual(args, ['auth', 'status', '--json'])
|
||||
assert.equal(options.env.AWS_PROFILE, env.AWS_PROFILE)
|
||||
assert.equal(options.env.AWS_SESSION_TOKEN, env.AWS_SESSION_TOKEN)
|
||||
assert.equal(options.env.CLAUDE_CODE_USE_BEDROCK, '1')
|
||||
assert.equal(options.env.GITHUB_TOKEN, undefined)
|
||||
return { stdout: JSON.stringify({ loggedIn: false }) }
|
||||
},
|
||||
})
|
||||
assert.equal(calls, 1)
|
||||
assert.deepEqual(result, { state: 'missing', source: 'native-auth-status' })
|
||||
})
|
||||
|
||||
test('Claude API key status remains configuration evidence rather than verified authentication', async () => {
|
||||
const result = await resolveNativeCredentialState('claude', {
|
||||
env: { ANTHROPIC_API_KEY: 'unverified-api-key' }, executable: '/tmp/claude',
|
||||
execFileFn: async () => ({ stdout: JSON.stringify({ loggedIn: true, authMethod: 'api_key' }) }),
|
||||
})
|
||||
assert.deepEqual(result, { state: 'ready', source: 'native-credential' })
|
||||
})
|
||||
|
||||
test('Claude cloud provider recognition alone does not verify SDK credentials', async () => {
|
||||
for (const apiProvider of ['bedrock', 'vertex', 'foundry']) {
|
||||
const result = await resolveNativeCredentialState('claude', {
|
||||
env: { ANTHROPIC_API_KEY: 'unused-direct-key' }, executable: '/tmp/claude',
|
||||
execFileFn: async () => ({ stdout: JSON.stringify({
|
||||
loggedIn: true, authMethod: 'third_party', apiProvider,
|
||||
}) }),
|
||||
})
|
||||
assert.deepEqual(result, { state: 'unknown', source: 'unverified' })
|
||||
}
|
||||
})
|
||||
|
||||
test('real shell extraction preserves explicit empty cloud and credential overrides', {
|
||||
skip: process.platform === 'win32',
|
||||
}, async () => {
|
||||
const execFile = require('node:util').promisify(require('node:child_process').execFile)
|
||||
const result = await resolveNativeShellEnvironment({
|
||||
cache: false, platform: 'linux', shell: '/bin/sh',
|
||||
env: { AWS_PROFILE: 'stale-profile', ANTHROPIC_API_KEY: 'stale-key', PATH: '/usr/bin:/bin' },
|
||||
execFileFn: (command, args, options) => execFile(command, ['-c',
|
||||
"export AWS_PROFILE='' ANTHROPIC_API_KEY='' CLAUDE_CODE_USE_BEDROCK='0'\n" + args[1],
|
||||
], options),
|
||||
})
|
||||
assert.equal(result.source, 'native-shell')
|
||||
assert.equal(result.env.AWS_PROFILE, '')
|
||||
assert.equal(result.env.ANTHROPIC_API_KEY, '')
|
||||
assert.equal(result.env.CLAUDE_CODE_USE_BEDROCK, '0')
|
||||
assert.deepEqual(nativeCredentialEnvironment('claude', result.env), {
|
||||
AWS_PROFILE: '', ANTHROPIC_API_KEY: '', CLAUDE_CODE_USE_BEDROCK: '0',
|
||||
})
|
||||
const fallback = await resolveNativeShellEnvironment({
|
||||
cache: false, platform: 'win32', env: { AWS_PROFILE: '', ANTHROPIC_API_KEY: '' },
|
||||
})
|
||||
assert.equal(fallback.env.AWS_PROFILE, '')
|
||||
assert.equal(fallback.env.ANTHROPIC_API_KEY, '')
|
||||
})
|
||||
|
||||
test('model-specific Vertex regions participate in cache identity and stay scoped to the native Agent', async () => {
|
||||
const key = 'VERTEX_REGION_CLAUDE_FUTURE_MODEL'
|
||||
const base = { platform: 'win32', home: 'C:\\vertex-region-test', env: { PATH: 'C:\\bin', [key]: 'us-east5' } }
|
||||
const before = await resolveNativeShellEnvironment(base)
|
||||
const after = await resolveNativeShellEnvironment({ ...base, env: { ...base.env, [key]: '' } })
|
||||
assert.equal(before.env[key], 'us-east5')
|
||||
assert.equal(after.env[key], '')
|
||||
assert.deepEqual(nativeCredentialEnvironment('claude', after.env), { [key]: '' })
|
||||
assert.deepEqual(nativeCredentialEnvironment('claude', before.env, { providerConfigured: true }), {})
|
||||
assert.deepEqual(nativeCredentialEnvironment('gemini', before.env), {})
|
||||
assert.deepEqual(nativeCredentialEnvironment('codex', before.env), {})
|
||||
const removed = await resolveNativeShellEnvironment({ ...base, env: { PATH: base.env.PATH } })
|
||||
assert.equal(removed.env[key], undefined)
|
||||
assert.deepEqual(nativeCredentialEnvironment('claude', {
|
||||
VERTEX_REGION_: 'empty-model', VERTEX_REGION_lowercase: 'invalid',
|
||||
'VERTEX_REGION_MODEL;bad': 'invalid', NODE_OPTIONS: '--require untrusted',
|
||||
}), {})
|
||||
})
|
||||
|
||||
for (const shell of ['/bin/sh', '/bin/bash', '/bin/zsh']) {
|
||||
test(`native shell ${shell} extracts exported dynamic regions without unrelated values or value evaluation`, {
|
||||
skip: process.platform === 'win32' || !fs.existsSync(shell),
|
||||
}, async t => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-region-'))
|
||||
t.after(() => fs.rmSync(home, { recursive: true, force: true }))
|
||||
const execFile = require('node:util').promisify(require('node:child_process').execFile)
|
||||
let output = ''
|
||||
const resolved = await resolveNativeShellEnvironment({
|
||||
cache: false, home, shell, env: { PATH: '/usr/bin:/bin' },
|
||||
execFileFn: async (command, args, options) => {
|
||||
const result = await execFile(command, ['-c', [
|
||||
"export VERTEX_REGION_CLAUDE_FUTURE_MODEL=us-east5",
|
||||
"export VERTEX_REGION_CLAUDE_EMPTY=''",
|
||||
"export VERTEX_REGION_LITERAL='$(touch should-not-exist)'",
|
||||
"export MELDWORK_UNRELATED='synthetic-unrelated-secret'",
|
||||
"export NODE_OPTIONS='--require /nonexistent/untrusted-module.cjs'",
|
||||
args[1],
|
||||
].join('\n')], { ...options, cwd: home })
|
||||
output = result.stdout
|
||||
return result
|
||||
},
|
||||
})
|
||||
assert.equal(resolved.source, 'native-shell')
|
||||
assert.equal(resolved.env.VERTEX_REGION_CLAUDE_FUTURE_MODEL, 'us-east5')
|
||||
assert.equal(resolved.env.VERTEX_REGION_CLAUDE_EMPTY, '')
|
||||
assert.equal(resolved.env.VERTEX_REGION_LITERAL, '$(touch should-not-exist)')
|
||||
assert.equal(resolved.env.MELDWORK_UNRELATED, undefined)
|
||||
assert.equal(resolved.env.NODE_OPTIONS, undefined)
|
||||
assert.equal(output.includes('synthetic-unrelated-secret'), false)
|
||||
assert.equal(fs.existsSync(path.join(home, 'should-not-exist')), false)
|
||||
})
|
||||
}
|
||||
|
||||
test('native cloud configuration is forwarded only to compatible Agents', () => {
|
||||
const cloud = {
|
||||
CLAUDE_CODE_USE_VERTEX: '1', AWS_PROFILE: 'native', AWS_ACCESS_KEY_ID: 'access-key',
|
||||
AWS_SECRET_ACCESS_KEY: 'secret-key', AZURE_CLIENT_SECRET: 'azure-secret',
|
||||
GOOGLE_APPLICATION_CREDENTIALS: '/tmp/adc.json', GOOGLE_CLOUD_PROJECT: 'native-project',
|
||||
CLOUD_ML_REGION: 'us-east5', ANTHROPIC_FOUNDRY_RESOURCE: 'native-resource',
|
||||
}
|
||||
assert.deepEqual(nativeCredentialEnvironment('claude', cloud), cloud)
|
||||
assert.deepEqual(nativeCredentialEnvironment('gemini', cloud), {
|
||||
GOOGLE_APPLICATION_CREDENTIALS: '/tmp/adc.json', GOOGLE_CLOUD_PROJECT: 'native-project',
|
||||
})
|
||||
assert.deepEqual(nativeCredentialEnvironment('codex', cloud), {})
|
||||
})
|
||||
|
||||
test('selected Provider retains native configuration roots without native credentials or selectors', () => {
|
||||
const env = {
|
||||
CLAUDE_CONFIG_DIR: '/tmp/claude-config', CODEX_HOME: '/tmp/codex-config',
|
||||
PI_CODING_AGENT_DIR: '/tmp/pi-config', AWS_PROFILE: 'native',
|
||||
CLAUDE_CODE_USE_BEDROCK: '1', ANTHROPIC_API_KEY: 'native-key',
|
||||
ANTHROPIC_BASE_URL: 'https://native.example', OPENAI_API_KEY: 'other-key',
|
||||
}
|
||||
for (const [kind, root] of [
|
||||
['claude', 'CLAUDE_CONFIG_DIR'], ['codex', 'CODEX_HOME'], ['pi', 'PI_CODING_AGENT_DIR'],
|
||||
]) {
|
||||
assert.deepEqual(nativeCredentialEnvironment(kind, env, { providerConfigured: true }), {
|
||||
[root]: env[root],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test('an installed Agent without credential evidence remains unverified', () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-missing-readiness-'))
|
||||
try {
|
||||
|
||||
@@ -2396,6 +2396,20 @@ test('round-trips bounded recipient delivery acknowledgement without a native Se
|
||||
})
|
||||
assert.equal(record.deliveryState?.length, 1)
|
||||
assert.deepEqual(parseOrchestrationV4(record, { targetKinds: ['codex', 'hermes'] }), record)
|
||||
const natural = parseOrchestrationV4({
|
||||
...record,
|
||||
deliveryState: [{ ...record.deliveryState[0], sourceMessages: [{ id: 'message-1', hash: '5'.repeat(64) }] }],
|
||||
}, { targetKinds: ['codex', 'hermes'] })
|
||||
assert.deepEqual(natural.deliveryState[0].sourceMessages, [{ id: 'message-1', hash: '5'.repeat(64) }])
|
||||
for (const sourceMessages of [
|
||||
[], [{ id: 'message-1', hash: 'bad' }], [{ id: 'message-1', hash: '5'.repeat(64), text: 'do not persist' }],
|
||||
Array.from({ length: 101 }, (_, index) => ({ id: `message-${index}`, hash: '5'.repeat(64) })),
|
||||
[{ id: 'message-1', hash: '5'.repeat(64) }, { id: 'message-1', hash: '6'.repeat(64) }],
|
||||
]) {
|
||||
assert.throws(() => parseOrchestrationV4({
|
||||
...record, deliveryState: [{ ...record.deliveryState[0], sourceMessages }],
|
||||
}, { targetKinds: ['codex', 'hermes'] }), { code: 'ORCHESTRATION_V4_DELIVERY_STATE_INVALID' })
|
||||
}
|
||||
assert.throws(() => parseOrchestrationV4({
|
||||
...record,
|
||||
deliveryState: [{ ...record.deliveryState[0], sessionRef: 'must-not-persist' }],
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const { parseTaskDecision } = require('../../src/collaboration/task-decision.cjs')
|
||||
|
||||
test('explicit next participants survive normalization without changing legacy decisions', () => {
|
||||
const legacy = { status: 'continue', reason: 'More evidence is needed.', deliverables: [] }
|
||||
assert.deepEqual(parseTaskDecision(legacy), legacy)
|
||||
for (const nextKinds of [[], ['hermes'], ['hermes', 'codex']]) {
|
||||
const decision = { ...legacy, nextKinds }
|
||||
assert.deepEqual(parseTaskDecision(decision), decision)
|
||||
assert.notStrictEqual(parseTaskDecision(decision).nextKinds, nextKinds)
|
||||
}
|
||||
assert.deepEqual(parseTaskDecision({ ...legacy, nextKinds: ['OpenClaw'] }).nextKinds, ['openclaw'])
|
||||
const fullGroup = Array.from({ length: 32 }, (_, i) => `agent-${i}`)
|
||||
assert.deepEqual(parseTaskDecision({ ...legacy, nextKinds: fullGroup }).nextKinds, fullGroup)
|
||||
for (const nextKinds of ['hermes', ['@hermes'], ['hermes', 'HERMES'], [null], [''], ['two words'],
|
||||
[...fullGroup, 'agent-32']]) {
|
||||
assert.throws(() => parseTaskDecision({ ...legacy, nextKinds }), /LOCAL_RUN_TASK_DECISION_INVALID/)
|
||||
}
|
||||
assert.throws(() => parseTaskDecision({ ...legacy, status: 'completed',
|
||||
deliverables: ['Result'], nextKinds: ['hermes'] }), /LOCAL_RUN_TASK_DECISION_INVALID/)
|
||||
})
|
||||
|
||||
test('completion requires an explicit reason and deliverable evidence', () => {
|
||||
const decision = { status: 'completed', reason: 'Checked the requested output.', deliverables: ['result.txt'] }
|
||||
assert.deepEqual(parseTaskDecision(decision), decision)
|
||||
for (const input of [
|
||||
{ ...decision, deliverables: [] },
|
||||
{ ...decision, reason: ' ' },
|
||||
{ ...decision, deliverables: [''] },
|
||||
{ ...decision, status: 'success' },
|
||||
{ ...decision, extra: true },
|
||||
{ ...decision, handoffTo: 'other' },
|
||||
]) assert.throws(() => parseTaskDecision(input), /LOCAL_RUN_TASK_DECISION_INVALID/)
|
||||
})
|
||||
|
||||
test('unfinished decisions permit no deliverable and continuation permits an explicit handoff', () => {
|
||||
for (const status of ['continue', 'blocked', 'needs-human']) {
|
||||
const decision = { status, reason: 'More input is needed.', deliverables: [] }
|
||||
assert.deepEqual(parseTaskDecision(decision), decision)
|
||||
}
|
||||
const decision = { status: 'continue', reason: 'Review the output.', deliverables: [], handoffTo: 'custom-agent' }
|
||||
assert.deepEqual(parseTaskDecision(decision), decision)
|
||||
assert.throws(() => parseTaskDecision({ ...decision, handoffTo: '@ghost' }), /LOCAL_RUN_TASK_DECISION_INVALID/)
|
||||
})
|
||||
|
||||
test('task decisions reject malformed and oversized payloads', () => {
|
||||
const decision = { status: 'continue', reason: 'Continue.', deliverables: [] }
|
||||
for (const input of [
|
||||
null, [], 'completed',
|
||||
{ ...decision, reason: 'x'.repeat(1601) },
|
||||
{ ...decision, reason: 'bad\u0000input' },
|
||||
{ ...decision, deliverables: 'output' },
|
||||
{ ...decision, deliverables: Array(17).fill('output') },
|
||||
{ ...decision, deliverables: ['x'.repeat(801)] },
|
||||
{ ...decision, deliverables: [42] },
|
||||
]) assert.throws(() => parseTaskDecision(input), /LOCAL_RUN_TASK_DECISION_INVALID/)
|
||||
})
|
||||
@@ -64,6 +64,25 @@ test('persists a pending gate and resumes its live waiter after one decision', a
|
||||
assert.deepEqual(events.map(value => value.split(':', 1)[0]), ['waiting', 'resumed'])
|
||||
})
|
||||
|
||||
test('repeated decisions return the persisted result without replaying resume callbacks', async t => {
|
||||
let resumed = 0
|
||||
let orphaned = 0
|
||||
let tick = 0
|
||||
const { coordinator, store } = fixture(t, {
|
||||
now: () => new Date(Date.UTC(2026, 7, 4, 0, 0, tick++)).toISOString(),
|
||||
onResumed: () => { resumed += 1 },
|
||||
onOrphanDecision: () => { orphaned += 1 },
|
||||
})
|
||||
const gate = store.create({ ...request(), createdAt: '2026-08-04T00:00:00.000Z' })
|
||||
const first = coordinator.decide(gate.gateId, { optionId: 'allow-once' })
|
||||
assert.deepEqual(coordinator.decide(gate.gateId, { optionId: 'allow-once' }), first)
|
||||
await coordinator.resumePromises.get(gate.gateId)
|
||||
assert.deepEqual(coordinator.decide(gate.gateId, { optionId: 'allow-once' }), first)
|
||||
assert.equal(resumed, 1)
|
||||
assert.equal(orphaned, 1)
|
||||
assert.throws(() => coordinator.decide(gate.gateId, { optionId: 'reject-once' }))
|
||||
})
|
||||
|
||||
test('aborting a waiter rejects the request and durably selects a reject option', async (t) => {
|
||||
const { coordinator } = fixture(t)
|
||||
const controller = new AbortController()
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const afterAllArtifactBuild = require('../../scripts/after-all-artifact-build.cjs')
|
||||
|
||||
test('DMG post-processing ignores non-DMG release artifacts', () => {
|
||||
assert.deepEqual(afterAllArtifactBuild.selectDmgArtifacts([
|
||||
'/dist/Meldwork-arm64.zip',
|
||||
'/dist/Meldwork-arm64.dmg.blockmap',
|
||||
'/dist/Meldwork-arm64.dmg',
|
||||
]), ['/dist/Meldwork-arm64.dmg'])
|
||||
})
|
||||
|
||||
test('DMG post-processing removes Finder-visible root resources', () => {
|
||||
assert.deepEqual(afterAllArtifactBuild.ROOT_HIDDEN_RESOURCES, [
|
||||
'.background.tiff',
|
||||
'.VolumeIcon.icns',
|
||||
])
|
||||
})
|
||||
|
||||
test('DMG post-processing locates electron-builder bundled Python', () => {
|
||||
const cacheRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-dmg-cache-'))
|
||||
const pythonPath = path.join(
|
||||
cacheRoot,
|
||||
'[email protected]',
|
||||
'bundle',
|
||||
'python',
|
||||
'bin',
|
||||
'python3.14',
|
||||
)
|
||||
fs.mkdirSync(path.dirname(pythonPath), { recursive: true })
|
||||
fs.writeFileSync(pythonPath, '')
|
||||
|
||||
try {
|
||||
assert.equal(afterAllArtifactBuild.findBundledPython(cacheRoot), pythonPath)
|
||||
} finally {
|
||||
fs.rmSync(cacheRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('DMG post-processing extracts the mounted device and volume path', () => {
|
||||
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-dmg-plist-'))
|
||||
const plist = '<plist />'
|
||||
const run = (file, args) => {
|
||||
assert.equal(file, 'plutil')
|
||||
assert.deepEqual(args.slice(0, 4), ['-convert', 'json', '-o', '-'])
|
||||
return JSON.stringify({
|
||||
'system-entities': [
|
||||
{ 'dev-entry': '/dev/disk8', 'potentially-mountable': false },
|
||||
{ 'dev-entry': '/dev/disk8s1', 'mount-point': '/Volumes/Meldwork' },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
assert.deepEqual(afterAllArtifactBuild.parseAttachedVolume(plist, workDir, run), {
|
||||
device: '/dev/disk8s1',
|
||||
mountPath: '/Volumes/Meldwork',
|
||||
})
|
||||
} finally {
|
||||
fs.rmSync(workDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -36,15 +36,26 @@ test('packaged application carries the open-source and commercial licensing noti
|
||||
|
||||
test('DMG uses the branded background and fixed drag layout', () => {
|
||||
const dmg = desktopPackage.build.dmg
|
||||
assert.equal(dmg.background, 'build/dmg-background-ai-v2.png')
|
||||
assert.deepEqual(dmg.window, { width: 540, height: 380 })
|
||||
assert.equal(dmg.iconSize, 128)
|
||||
assert.equal(desktopPackage.build.afterAllArtifactBuild, 'scripts/after-all-artifact-build.cjs')
|
||||
assert.equal(dmg.background, 'build/dmg-background-ai-v4.png')
|
||||
assert.equal(dmg.icon, null)
|
||||
assert.equal(dmg.writeUpdateInfo, false)
|
||||
assert.deepEqual(dmg.window, { width: 640, height: 420 })
|
||||
assert.equal(dmg.iconSize, 112)
|
||||
assert.deepEqual(dmg.contents, [
|
||||
{ x: 145, y: 210, type: 'file' },
|
||||
{ x: 395, y: 210, type: 'link', path: '/Applications' },
|
||||
{ x: 110, y: 205, type: 'file' },
|
||||
{ x: 520, y: 205, type: 'link', path: '/Applications' },
|
||||
])
|
||||
|
||||
const background = fs.readFileSync(path.join(__dirname, '..', '..', dmg.background))
|
||||
assert.equal(background.readUInt32BE(16), 540)
|
||||
assert.equal(background.readUInt32BE(20), 380)
|
||||
assert.equal(background.readUInt32BE(16), 640)
|
||||
assert.equal(background.readUInt32BE(20), 420)
|
||||
|
||||
assert.equal(
|
||||
desktopPackage.build.extraResources.some(entry => (
|
||||
entry.from === 'build/dmg-background-ai-v4.tiff'
|
||||
&& entry.to === 'dmg-background.tiff'
|
||||
)),
|
||||
true,
|
||||
)
|
||||
})
|
||||
@@ -59,6 +59,7 @@ test('Provider options map native Agent credential names without dropping generi
|
||||
},
|
||||
claude: {
|
||||
ANTHROPIC_API_KEY: 'provider-key',
|
||||
MELDWORK_PROVIDER_API_KEY: 'provider-key',
|
||||
ANTHROPIC_BASE_URL: 'https://api.example.com/v1',
|
||||
ANTHROPIC_MODEL: 'example-model',
|
||||
},
|
||||
@@ -71,6 +72,7 @@ test('Provider options map native Agent credential names without dropping generi
|
||||
|
||||
for (const [kind, values] of Object.entries(expected)) {
|
||||
assert.deepEqual(providerOptionsFor(kind, GENERIC), {
|
||||
...(kind === 'claude' ? { provider: { id: 'anthropic', model: 'example-model', baseUrl: GENERIC.OPENAI_BASE_URL } } : {}),
|
||||
env: { ...GENERIC, ...values },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -332,12 +332,40 @@ test('corrupt credentials stay unavailable and are never returned', (t) => {
|
||||
mode: 0o600,
|
||||
})
|
||||
|
||||
assert.deepEqual(store.status('hermes'), emptyStatus(true))
|
||||
assert.deepEqual(store.status('hermes'), { ...emptyStatus(true), error: true })
|
||||
assert.throws(() => store.envForAgent('hermes'), {
|
||||
message: 'PROVIDER_CREDENTIAL_UNAVAILABLE',
|
||||
})
|
||||
})
|
||||
|
||||
test('temporarily locked credentials preserve saved profiles and recover without reconfiguration', (t) => {
|
||||
const safeStorage = encryptedSafeStorage()
|
||||
const { directory, storagePath, store } = fixture(safeStorage)
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
store.save('hermes', credential())
|
||||
const contents = fs.readFileSync(storagePath, 'utf8')
|
||||
const decrypt = safeStorage.decryptString
|
||||
safeStorage.isEncryptionAvailable = () => false
|
||||
|
||||
const locked = store.status('hermes')
|
||||
assert.equal(locked.error, true)
|
||||
assert.equal(locked.configured, false)
|
||||
assert.equal(locked.activePreset, 'custom')
|
||||
assert.deepEqual(locked.profiles.custom, { ...PROVIDER, configured: false })
|
||||
assert.throws(() => store.save('hermes', credential('replacement')), {
|
||||
message: 'PROVIDER_ENCRYPTION_UNAVAILABLE',
|
||||
})
|
||||
assert.equal(fs.readFileSync(storagePath, 'utf8'), contents)
|
||||
|
||||
safeStorage.isEncryptionAvailable = () => true
|
||||
safeStorage.decryptString = () => { throw new Error('temporarily locked') }
|
||||
assert.equal(store.status('hermes').error, true)
|
||||
assert.equal(fs.readFileSync(storagePath, 'utf8'), contents)
|
||||
safeStorage.decryptString = decrypt
|
||||
assert.deepEqual(store.status('hermes'), configuredStatus())
|
||||
assert.equal(store.envForAgent('hermes').OPENAI_API_KEY, 'test-provider-key')
|
||||
})
|
||||
|
||||
test('delete removes the local credential even when encryption is unavailable', (t) => {
|
||||
const safeStorage = encryptedSafeStorage()
|
||||
const { directory, storagePath, store } = fixture(safeStorage)
|
||||
|
||||
@@ -1704,6 +1704,29 @@ test('Outcome references survive checkpoint, journal recovery, and restart', (t)
|
||||
assert.equal(restored.snapshotError instanceof Error, true)
|
||||
})
|
||||
|
||||
test('task judgments survive journal recovery and malformed completion evidence is rejected', (t) => {
|
||||
const { storagePath } = fixture(t)
|
||||
const taskDecision = {
|
||||
status: 'completed', reason: 'Checked the delivered answer.', deliverables: ['The answer is 42.'],
|
||||
}
|
||||
const ledger = new RunLedger({ storagePath, now: () => 1000 })
|
||||
ledger.checkpoint(runRecord('run-decision', 'group-decision', 'completed', [{
|
||||
agentRunId: 'agent-decision', kind: 'codex', status: 'completed', context: { taskDecision },
|
||||
}]))
|
||||
assert.deepEqual(ledger.get('run-decision').agentRuns[0].context.taskDecision, taskDecision)
|
||||
const journalText = fs.readFileSync(ledger.journalPath, 'utf8')
|
||||
const prepare = journalText.split('\n').filter(Boolean).map(line => JSON.parse(line))
|
||||
.find(entry => entry.change?.upserts?.some(run => run.runId === 'run-decision'))
|
||||
assert.ok(prepare)
|
||||
const malformed = structuredClone(prepare.change)
|
||||
malformed.upserts.find(run => run.runId === 'run-decision').agentRuns[0].context.taskDecision.deliverables = []
|
||||
assert.throws(() => ledger.journal.prepare(malformed, 1001), /RUN_JOURNAL_CHANGE_INVALID/)
|
||||
assert.equal(fs.readFileSync(ledger.journalPath, 'utf8'), journalText)
|
||||
fs.writeFileSync(storagePath, '{corrupt snapshot', 'utf8')
|
||||
const restored = new RunLedger({ storagePath, now: () => 2000 })
|
||||
assert.deepEqual(restored.get('run-decision').agentRuns[0].context.taskDecision, taskDecision)
|
||||
})
|
||||
|
||||
test('committed journal lifecycle wins over a valid stale snapshot', (t) => {
|
||||
const { storagePath } = fixture(t)
|
||||
let now = 1000
|
||||
|
||||
@@ -107,6 +107,83 @@ test('enforces Task and Workspace limits independently', async () => {
|
||||
;(await workspaceQueued).release()
|
||||
})
|
||||
|
||||
test('workspace writers serialize across tasks while readers and other directories remain concurrent', async () => {
|
||||
const scheduler = new RunScheduler()
|
||||
const lease = await scheduler.acquire({
|
||||
taskId: 'writer', workspaceKey: 'shared', permissionMode: 'workspace-write',
|
||||
})
|
||||
const reader = await scheduler.acquire({ taskId: 'reader', workspaceKey: 'shared' })
|
||||
const nextWriter = scheduler.acquire({
|
||||
taskId: 'writer2', workspaceKey: 'shared', permissionMode: 'workspace-write',
|
||||
})
|
||||
const nextState = deferredState(nextWriter)
|
||||
const other = await scheduler.acquire({
|
||||
taskId: 'other', workspaceKey: 'other', permissionMode: 'workspace-write',
|
||||
})
|
||||
const laterReader = await scheduler.acquire({ taskId: 'later', workspaceKey: 'shared' })
|
||||
assert.equal(lease.permissionMode, 'workspace-write')
|
||||
assert.equal(nextState.settled, false)
|
||||
lease.release()
|
||||
;(await nextWriter).release()
|
||||
reader.release()
|
||||
laterReader.release()
|
||||
other.release()
|
||||
assert.equal(scheduler.snapshot().active.global, 0)
|
||||
})
|
||||
|
||||
test('cancelling a queued writer does not block the next writer', async () => {
|
||||
const scheduler = new RunScheduler()
|
||||
const first = await scheduler.acquire({
|
||||
taskId: 'first', workspaceKey: 'shared', permissionMode: 'workspace-write',
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const writer = scheduler.acquire({
|
||||
taskId: 'writer', workspaceKey: 'shared', permissionMode: 'workspace-write', signal: controller.signal,
|
||||
})
|
||||
const later = scheduler.acquire({ taskId: 'later', workspaceKey: 'shared', permissionMode: 'workspace-write' })
|
||||
controller.abort()
|
||||
await assert.rejects(writer, { message: 'RUN_SCHEDULER_ABORTED' })
|
||||
first.release()
|
||||
;(await later).release()
|
||||
})
|
||||
|
||||
test('a suspended writer reacquires exclusive access using its original permission', async () => {
|
||||
const scheduler = new RunScheduler()
|
||||
const gate = deferred()
|
||||
const paused = deferred()
|
||||
const input = { taskId: 'writer', workspaceKey: 'shared', permissionMode: 'workspace-write' }
|
||||
const writer = scheduler.withLease(input, async lease => {
|
||||
await lease.suspend(async () => {
|
||||
paused.resolve()
|
||||
await gate.promise
|
||||
}, { pauseTask: true })
|
||||
assert.equal(lease.permissionMode, 'workspace-write')
|
||||
})
|
||||
await paused.promise
|
||||
input.permissionMode = 'read-only'
|
||||
const otherWriter = await scheduler.acquire({
|
||||
taskId: 'other', workspaceKey: 'shared', permissionMode: 'workspace-write',
|
||||
})
|
||||
const sibling = scheduler.acquire({
|
||||
taskId: 'writer', workspaceKey: 'shared', permissionMode: 'workspace-write',
|
||||
})
|
||||
const siblingState = deferredState(sibling)
|
||||
gate.resolve()
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
assert.equal(scheduler.snapshot().queued.length, 2)
|
||||
assert.equal(siblingState.settled, false)
|
||||
otherWriter.release()
|
||||
await writer
|
||||
;(await sibling).release()
|
||||
assert.equal(scheduler.snapshot().active.global, 0)
|
||||
})
|
||||
|
||||
test('rejects unknown scheduler permissions', async () => {
|
||||
await assert.rejects(new RunScheduler().acquire({
|
||||
taskId: 'task', workspaceKey: 'workspace', permissionMode: 'unrestricted',
|
||||
}), { message: 'RUN_SCHEDULER_PERMISSION_INVALID' })
|
||||
})
|
||||
|
||||
test('grants later eligible work when an earlier request is blocked by a scoped limit', async () => {
|
||||
const scheduler = new RunScheduler({ taskLimit: 1, workspaceLimit: 3, globalLimit: 2 })
|
||||
const active = await scheduler.acquire({ taskId: 'same-task', workspaceKey: 'workspace-a' })
|
||||
|
||||
@@ -1996,12 +1996,11 @@ test('configured Provider is injected only through local Agent execution options
|
||||
const { harness } = loadMain(directory, {
|
||||
providerConfigured: true,
|
||||
nativeShellEnvironment: () => ({
|
||||
env: { PATH: '/opt/native-agent/bin:/usr/bin' },
|
||||
env: { PATH: '/opt/native-agent/bin:/usr/bin', https_proxy: 'http://proxy.example:3128', NO_PROXY: 'localhost,127.0.0.1', ANTHROPIC_API_KEY: 'native-hermes-key' },
|
||||
source: 'native-shell',
|
||||
}),
|
||||
nativeEnvironment: kind => kind === 'hermes'
|
||||
? { ANTHROPIC_API_KEY: 'native-hermes-key' }
|
||||
: {},
|
||||
nativeEnvironment: (...args) => require('../../src/agents/local-agent-readiness.cjs')
|
||||
.nativeCredentialEnvironment(...args),
|
||||
})
|
||||
await harness.ready()
|
||||
|
||||
@@ -2017,9 +2016,37 @@ test('configured Provider is injected only through local Agent execution options
|
||||
assert.equal(options.env.OPENAI_MODEL, PROVIDER_METADATA.model)
|
||||
assert.equal(options.env.HERMES_INFERENCE_PROVIDER, 'openai-api')
|
||||
assert.equal(options.env.HERMES_INFERENCE_MODEL, PROVIDER_METADATA.model)
|
||||
assert.equal(options.env.ANTHROPIC_API_KEY, 'native-hermes-key')
|
||||
assert.equal(options.env.ANTHROPIC_API_KEY, undefined)
|
||||
assert.equal(options.env.CURRENT_RUN, '1')
|
||||
assert.equal(options.env.PATH, '/opt/native-agent/bin:/usr/bin')
|
||||
assert.equal(options.env.https_proxy, 'http://proxy.example:3128')
|
||||
assert.equal(options.env.NO_PROXY, 'localhost,127.0.0.1')
|
||||
})
|
||||
|
||||
test('unreadable saved Provider credentials never silently fall back to native execution', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-provider-locked-'))
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const { harness } = loadMain(directory)
|
||||
await harness.ready()
|
||||
const store = harness.providerInstances[0]
|
||||
const originalStatus = store.status.bind(store)
|
||||
store.status = () => ({ configured: false, error: true, activePreset: 'custom' })
|
||||
assert.deepEqual(await harness.workspaceInstances[0].input.credentialState('hermes'), {
|
||||
state: 'unknown', source: 'provider-credential-unavailable',
|
||||
})
|
||||
await assert.rejects(harness.workspaceInstances[0].input.runAgent(
|
||||
{ kind: 'hermes', executable: '/tmp/hermes' }, 'hello', directory,
|
||||
), { message: 'PROVIDER_CREDENTIAL_UNAVAILABLE' })
|
||||
assert.equal(harness.runAgentCalls.length, 0)
|
||||
|
||||
store.status = originalStatus
|
||||
assert.deepEqual(await harness.workspaceInstances[0].input.credentialState('hermes'), {
|
||||
state: 'ready', source: 'native-credential',
|
||||
})
|
||||
await harness.workspaceInstances[0].input.runAgent(
|
||||
{ kind: 'hermes', executable: '/tmp/hermes' }, 'hello', directory,
|
||||
)
|
||||
assert.equal(harness.runAgentCalls.length, 1)
|
||||
})
|
||||
|
||||
test('OpenClaw native auth is routed through the app-owned isolated runtime', async (t) => {
|
||||
@@ -2047,7 +2074,7 @@ test('OpenClaw native auth is routed through the app-owned isolated runtime', as
|
||||
},
|
||||
}),
|
||||
resolveNativeShellEnvironment: async () => ({
|
||||
env: { PATH: '/opt/native-agent/bin:/usr/bin' },
|
||||
env: { PATH: '/opt/native-agent/bin:/usr/bin', https_proxy: 'http://proxy.example:3128' },
|
||||
source: 'native-shell',
|
||||
}),
|
||||
resolveNativeCredentialState: async (kind, input) => {
|
||||
@@ -2082,6 +2109,7 @@ test('OpenClaw native auth is routed through the app-owned isolated runtime', as
|
||||
},
|
||||
)
|
||||
const options = harness.runAgentCalls[0][3]
|
||||
assert.equal(options.env.https_proxy, 'http://proxy.example:3128')
|
||||
assert.equal(Object.hasOwn(options.env, 'OPENCLAW_NATIVE_CONFIG'), false)
|
||||
assert.equal(Object.hasOwn(options.env, 'OPENAI_API_KEY'), false)
|
||||
assert.equal(Object.hasOwn(options.env, 'OPENROUTER_API_KEY'), false)
|
||||
@@ -2103,6 +2131,26 @@ test('OpenClaw native auth is routed through the app-owned isolated runtime', as
|
||||
})
|
||||
})
|
||||
|
||||
test('manual Agent refresh updates native shell settings before detection and credential checks', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-refresh-environment-'))
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
let configured = 'before'
|
||||
let cached = 'before'
|
||||
const refreshes = []
|
||||
const { harness } = loadMain(directory, {
|
||||
nativeShellEnvironment: input => {
|
||||
if (input.refresh) { cached = configured; refreshes.push(cached) }
|
||||
return { env: { ANTHROPIC_API_KEY: `synthetic-${cached}` }, source: 'native-shell' }
|
||||
},
|
||||
onRefresh: async () => { assert.equal(cached, configured) },
|
||||
})
|
||||
await harness.ready()
|
||||
configured = 'after'
|
||||
await harness.ipcHandlers.get('local-workspace:refresh-agents')(harness.event())
|
||||
assert.deepEqual(refreshes, ['after'])
|
||||
assert.equal(harness.workspaceInstances[0].refreshCount, 1)
|
||||
})
|
||||
|
||||
test('manual Agent refreshes remain serialized', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-refresh-'))
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
|
||||
@@ -825,7 +825,7 @@ function loadMain(userData, options = {}) {
|
||||
'./agents/custom-agent-store.cjs': { CustomAgentStore: TestCustomAgentStore },
|
||||
'./attachments/attachment-store.cjs': { AttachmentStore: TestAttachmentStore },
|
||||
'./agents/local-agent-readiness.cjs': {
|
||||
nativeCredentialEnvironment: kind => options.nativeEnvironment?.(kind) || {},
|
||||
nativeCredentialEnvironment: (...args) => options.nativeEnvironment?.(...args) || {},
|
||||
resolveNativeShellEnvironment: async input => options.nativeShellEnvironment?.(input) || ({
|
||||
env: { PATH: process.env.PATH || '' },
|
||||
source: 'process',
|
||||
|
||||
@@ -46,6 +46,58 @@ test('Agent catalog module exposes only its coordinator', () => {
|
||||
assert.deepEqual(Object.keys(catalogApi), ['LocalWorkspaceAgentCatalog'])
|
||||
})
|
||||
|
||||
test('one credential probe failure retains that installation and does not hide healthy peers', async () => {
|
||||
const { catalog } = fixture({
|
||||
detectAgents: async () => ['codex', 'claude'].map(kind => ({ kind, compatibilityState: 'compatible' })),
|
||||
credentialState: async (kind) => {
|
||||
if (kind === 'codex') throw new Error('probe failed')
|
||||
return { state: 'ready', source: 'native-auth-status' }
|
||||
},
|
||||
})
|
||||
const { agents } = await catalog.refresh()
|
||||
assert.deepEqual(agents.map(agent => agent.kind), ['codex', 'claude'])
|
||||
assert.deepEqual(agents.map(agent => agent.installed), [true, true])
|
||||
assert.deepEqual(agents.map(agent => agent.available), [false, true])
|
||||
assert.equal(agents[0].credentialState, 'unknown')
|
||||
assert.equal(agents[0].availabilitySource, 'native-probe-failed')
|
||||
})
|
||||
|
||||
test('unreadable selected Provider overrides historical success without losing installation or native auth', async () => {
|
||||
let locked = true
|
||||
const { catalog, state } = fixture({
|
||||
detectAgents: async () => ['hermes', 'codex'].map(kind => ({ kind, compatibilityState: 'compatible' })),
|
||||
credentialState: async kind => kind === 'hermes' && locked
|
||||
? { state: 'unknown', source: 'provider-credential-unavailable' }
|
||||
: { state: 'ready', source: 'native-auth-status' },
|
||||
sharedProviderReady: kind => kind === 'hermes' && !locked,
|
||||
})
|
||||
state.messages.push({ role: 'agent', agentKind: 'hermes' })
|
||||
const verified = { credentialState: 'ready', checkedAt: '2026-08-03T00:00:00.000Z' }
|
||||
state.agentRuntime.hermes = verified
|
||||
|
||||
const first = await catalog.refresh()
|
||||
const agent = first.agents[0]
|
||||
assert.equal(agent.installed, true)
|
||||
assert.equal(agent.configured, true)
|
||||
assert.equal(agent.authenticated, false)
|
||||
assert.equal(agent.invocable, false)
|
||||
assert.equal(agent.available, false)
|
||||
assert.equal(agent.recentlyVerified, false)
|
||||
assert.equal(agent.credentialState, 'unknown')
|
||||
assert.equal(agent.availabilitySource, 'provider-credential-unavailable')
|
||||
assert.equal(first.agents[1].available, true)
|
||||
assert.equal(state.agentRuntime.hermes, verified)
|
||||
catalog.markRuntimeCredential('hermes', 'ready')
|
||||
assert.equal(agent.available, false)
|
||||
assert.equal(agent.credentialState, 'unknown')
|
||||
assert.equal(agent.availabilitySource, 'provider-credential-unavailable')
|
||||
|
||||
locked = false
|
||||
const second = await catalog.refresh()
|
||||
assert.equal(second.agents[0].available, true)
|
||||
assert.equal(second.agents[0].availabilitySource, 'shared-provider')
|
||||
})
|
||||
|
||||
test('refresh starts credential checks concurrently and reads current runtime state after them', async () => {
|
||||
const codex = deferred()
|
||||
const hermes = deferred()
|
||||
@@ -134,14 +186,15 @@ test('incompatible Agents remain installed but unavailable with ready credential
|
||||
assert.equal(agents()[0].invocable, false)
|
||||
assert.equal(agents()[0].credentialState, 'ready')
|
||||
assert.equal(agents()[0].available, false)
|
||||
assert.equal(agents()[0].showInSidebar, false)
|
||||
assert.equal(agents()[0].showInSidebar, true)
|
||||
assert.equal(agents()[0].availabilitySource, 'incompatible')
|
||||
assert.equal(agents()[0].incompatibilityReason, 'LOCAL_AGENT_VERSION_UNSUPPORTED')
|
||||
assert.throws(
|
||||
() => catalog.setSidebarVisibility('codex', true),
|
||||
{ message: 'LOCAL_AGENT_UNAVAILABLE' },
|
||||
)
|
||||
assert.deepEqual(events, ['emit', 'snapshot'])
|
||||
catalog.setSidebarVisibility('codex', false)
|
||||
assert.equal(agents()[0].showInSidebar, false)
|
||||
catalog.setSidebarVisibility('codex', true)
|
||||
assert.equal(agents()[0].showInSidebar, true)
|
||||
assert.equal(agents()[0].available, false)
|
||||
})
|
||||
|
||||
test('locally detected compatible Agents remain invocable when native configuration cannot be inspected', async () => {
|
||||
@@ -179,7 +232,9 @@ test('shared Provider readiness cannot override a recorded runtime auth failure'
|
||||
credentialState: async () => ({ state: 'ready', source: 'native-credential' }),
|
||||
sharedProviderReady: () => true,
|
||||
})
|
||||
state.agentRuntime.codex = { credentialState: 'missing' }
|
||||
state.agentRuntime.codex = {
|
||||
credentialState: 'missing', checkedAt: '2026-08-03T00:00:00.000Z',
|
||||
}
|
||||
|
||||
await catalog.refresh()
|
||||
|
||||
@@ -214,6 +269,69 @@ test('shared Provider readiness skips slow native credential probes', async () =
|
||||
assert.equal(agents()[0].available, true)
|
||||
})
|
||||
|
||||
test('expired auth failures allow native and shared credentials to be tried again', async () => {
|
||||
for (const shared of [false, true]) {
|
||||
const { agents, catalog, state } = fixture({
|
||||
detectAgents: async () => [{ kind: 'codex', compatibilityState: 'compatible' }],
|
||||
credentialState: async () => ({ state: 'unknown', source: 'unverified' }),
|
||||
sharedProviderReady: () => shared,
|
||||
})
|
||||
state.agentRuntime.codex = {
|
||||
credentialState: 'missing', checkedAt: '2026-08-02T23:59:29.000Z',
|
||||
}
|
||||
await catalog.refresh()
|
||||
assert.equal(agents()[0].invocable, true)
|
||||
assert.equal(state.agentRuntime.codex, undefined)
|
||||
assert.equal(agents()[0].recentlyVerified, false)
|
||||
}
|
||||
})
|
||||
|
||||
test('legacy authentication failures with missing or invalid timestamps permit re-evaluation', async () => {
|
||||
for (const checkedAt of [undefined, 'invalid', '2026-08-04T00:00:00.000Z']) {
|
||||
const { agents, catalog, state } = fixture({
|
||||
detectAgents: async () => [{ kind: 'pi', compatibilityState: 'compatible' }],
|
||||
})
|
||||
state.agentRuntime.pi = { credentialState: 'missing', checkedAt }
|
||||
await catalog.refresh()
|
||||
assert.equal(agents()[0].invocable, true)
|
||||
assert.equal(agents()[0].recentlyVerified, false)
|
||||
assert.equal(state.agentRuntime.pi, undefined)
|
||||
}
|
||||
})
|
||||
|
||||
test('recent runtime authentication failures stay blocked during the retry cooldown', async () => {
|
||||
const { agents, catalog, state } = fixture({
|
||||
detectAgents: async () => [{ kind: 'codex', compatibilityState: 'compatible' }],
|
||||
credentialState: async () => ({ state: 'ready', source: 'native-credential' }),
|
||||
})
|
||||
state.agentRuntime.codex = {
|
||||
credentialState: 'missing', checkedAt: '2026-08-02T23:59:45.000Z',
|
||||
}
|
||||
await catalog.refresh()
|
||||
assert.equal(agents()[0].invocable, false)
|
||||
assert.equal(agents()[0].availabilitySource, 'runtime-auth-failure')
|
||||
})
|
||||
|
||||
test('Claude API key and cloud status cannot erase a recent runtime authentication failure', async () => {
|
||||
const { resolveNativeCredentialState } = require('../../src/agents/local-agent-readiness.cjs')
|
||||
for (const authMethod of ['api_key', 'third_party']) {
|
||||
const { agents, catalog, state } = fixture({
|
||||
detectAgents: async () => [{ kind: 'claude', compatibilityState: 'compatible' }],
|
||||
credentialState: () => resolveNativeCredentialState('claude', {
|
||||
executable: '/tmp/claude', env: { ANTHROPIC_API_KEY: 'unverified-key' },
|
||||
execFileFn: async () => ({ stdout: JSON.stringify({ loggedIn: true, authMethod }) }),
|
||||
}),
|
||||
})
|
||||
state.agentRuntime.claude = {
|
||||
credentialState: 'missing', checkedAt: '2026-08-02T23:59:45.000Z',
|
||||
}
|
||||
await catalog.refresh()
|
||||
assert.equal(agents()[0].invocable, false)
|
||||
assert.equal(agents()[0].availabilitySource, 'runtime-auth-failure')
|
||||
assert.equal(state.agentRuntime.claude.credentialState, 'missing')
|
||||
}
|
||||
})
|
||||
|
||||
test('configured credentials do not make an Agent invocable when runtime prerequisites fail', async () => {
|
||||
const { agents, catalog } = fixture({
|
||||
detectAgents: async () => [{
|
||||
@@ -292,11 +410,10 @@ test('failed refresh preserves the previous Agent list without emitting changes'
|
||||
const previous = [{ kind: 'codex', available: true }]
|
||||
const { agents, catalog, events } = fixture({
|
||||
initialAgents: previous,
|
||||
detectAgents: async () => [{ kind: 'hermes' }],
|
||||
credentialState: async () => { throw new Error('credential probe failed') },
|
||||
detectAgents: async () => { throw new Error('installation discovery failed') },
|
||||
})
|
||||
|
||||
await assert.rejects(catalog.refresh(), { message: 'credential probe failed' })
|
||||
await assert.rejects(catalog.refresh(), { message: 'installation discovery failed' })
|
||||
assert.equal(agents(), previous)
|
||||
assert.deepEqual(events, [])
|
||||
})
|
||||
@@ -344,6 +461,25 @@ test('OpenCodeReview can be enabled in the conversation sidebar', () => {
|
||||
assert.equal(agents()[0].showInSidebar, true)
|
||||
})
|
||||
|
||||
test('runtime failure and recovery preserve sidebar preference independently of availability', async () => {
|
||||
const { catalog, agents } = fixture({
|
||||
detectAgents: async () => [{ kind: 'codex', compatibilityState: 'compatible' }],
|
||||
credentialState: async () => ({ state: 'ready', source: 'native-credential' }),
|
||||
})
|
||||
await catalog.refresh()
|
||||
catalog.markRuntimeCredential('codex', 'missing')
|
||||
assert.equal(agents()[0].available, false)
|
||||
assert.equal(agents()[0].showInSidebar, true)
|
||||
await catalog.refresh()
|
||||
assert.equal(agents()[0].showInSidebar, true)
|
||||
catalog.setSidebarVisibility('codex', false)
|
||||
catalog.markRuntimeCredential('codex', 'ready')
|
||||
assert.equal(agents()[0].available, true)
|
||||
assert.equal(agents()[0].showInSidebar, false)
|
||||
await catalog.refresh()
|
||||
assert.equal(agents()[0].showInSidebar, false)
|
||||
})
|
||||
|
||||
test('runtime credential cleanup removes only missing entries and never emits', () => {
|
||||
const { catalog, events, state } = fixture()
|
||||
state.agentRuntime = {
|
||||
|
||||
@@ -1413,7 +1413,7 @@ test('V4 invocation boundary resumes delta delivery from durable acknowledgement
|
||||
assert.ok(secondA.result)
|
||||
assert.equal(calls.find(call => call.tag === 'A' && call.attempt === 'wave-2').sessionRef,
|
||||
'A-codex-native-1')
|
||||
const sessionKeyA = workspace.sessionKey(runA.group.id, 'codex', runA.task.id)
|
||||
const sessionKeyA = workspace.sessionKey(runA.group.id, 'codex')
|
||||
workspace.state.sessionMeta[sessionKeyA] = {
|
||||
...workspace.state.sessionMeta[sessionKeyA],
|
||||
turns: 1000,
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -3999,7 +4002,7 @@ test('automatic dialogue reuses Kimi ACP sessions across rounds', async (t) => {
|
||||
await workspace.activeRuns.get(group.id).promise
|
||||
|
||||
const kimiCalls = calls.filter(call => call.agent.kind === 'kimi')
|
||||
const kimiSessionKey = workspace.sessionKey(group.id, 'kimi', started.threadRootId)
|
||||
const kimiSessionKey = workspace.sessionKey(group.id, 'kimi')
|
||||
assert.deepEqual(kimiCalls.map(call => call.runOptions.sessionRef), ['', 'kimi-acp-session'])
|
||||
assert.deepEqual(kimiCalls.map(call => call.runOptions.sessionTransport), ['', 'acp'])
|
||||
assert.equal(workspace.state.sessions[kimiSessionKey], 'kimi-acp-session')
|
||||
@@ -4040,7 +4043,7 @@ test('automatic dialogue keeps Hermes on one persistent ACP session across round
|
||||
await workspace.activeRuns.get(group.id).promise
|
||||
|
||||
const hermesCalls = calls.filter(call => call.agent.kind === 'hermes')
|
||||
const hermesSessionKey = workspace.sessionKey(group.id, 'hermes', started.threadRootId)
|
||||
const hermesSessionKey = workspace.sessionKey(group.id, 'hermes')
|
||||
assert.deepEqual(hermesCalls.map(call => call.runOptions.sessionRef), ['', 'hermes-acp-session'])
|
||||
assert.deepEqual(hermesCalls.map(call => call.runOptions.sessionTransport), ['', 'acp'])
|
||||
assert.deepEqual(hermesCalls.map(call => call.runOptions.hermesAcpAvailable), [true, true])
|
||||
@@ -4052,6 +4055,35 @@ test('automatic dialogue keeps Hermes on one persistent ACP session across round
|
||||
assert.equal(workspace.state.sessionMeta[hermesSessionKey].transport, 'acp')
|
||||
})
|
||||
|
||||
test('automatic dialogue keeps an active Agent runnable through a transient catalog outage', async (t) => {
|
||||
const { directory, calls, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
options.runAgent = async (agent, prompt, workdir, runOptions) => {
|
||||
calls.push({ agent, prompt, workdir, runOptions })
|
||||
if (calls.length === 2) {
|
||||
workspace.detectedAgents = workspace.detectedAgents.filter(item => item.kind !== 'openclaw')
|
||||
}
|
||||
const count = calls.filter(call => call.agent.kind === agent.kind).length
|
||||
return {
|
||||
text: `${agent.kind} ${count > 1 ? 'agree' : 'continue'}\n[[MELDWORK_CONSENSUS:${count > 1 ? 'agree' : 'continue'}]]`,
|
||||
sessionRef: runOptions.sessionRef || `${agent.kind}-session`,
|
||||
}
|
||||
}
|
||||
const workspace = new LocalWorkspace(options)
|
||||
await workspace.refreshAgents()
|
||||
const group = workspace.createGroup({
|
||||
name: 'Transient catalog outage', agentKinds: ['codex', 'openclaw'], workdir: directory,
|
||||
})
|
||||
|
||||
await workspace.sendMessage({ groupId: group.id, text: 'Continue across a transient refresh', mode: 'auto', maxRounds: 2 })
|
||||
await workspace.activeRuns.get(group.id).promise
|
||||
|
||||
assert.deepEqual(calls.map(call => call.agent.kind), ['codex', 'openclaw', 'codex', 'openclaw'])
|
||||
assert.equal(workspace.snapshot().messages.some(message => (
|
||||
message.agentKind === 'openclaw' && message.system?.key === 'system.agentCallFailed'
|
||||
)), false)
|
||||
})
|
||||
|
||||
test('automatic dialogue queues only the explicitly targeted group members', async (t) => {
|
||||
const { directory, calls, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
@@ -4076,14 +4108,6 @@ test('automatic dialogue queues only the explicitly targeted group members', asy
|
||||
workdir: directory,
|
||||
})
|
||||
|
||||
await assert.rejects(workspace.sendMessage({
|
||||
groupId: group.id,
|
||||
text: 'Only Codex',
|
||||
mode: 'auto',
|
||||
targetKinds: ['codex'],
|
||||
maxRounds: 1,
|
||||
}), { message: 'LOCAL_AUTO_AGENT_COUNT' })
|
||||
|
||||
await workspace.sendMessage({
|
||||
groupId: group.id,
|
||||
text: 'Codex、Hermes 和 Kimi 讨论',
|
||||
@@ -4752,6 +4776,34 @@ test('direct dialogue reports HTTP 401 once without removing its Agent', async (
|
||||
assert.equal(failures[0].system.params.reason, 'HTTP 401; authentication failed; Agent retained')
|
||||
})
|
||||
|
||||
test('group authentication recovery retains the existing native session', async (t) => {
|
||||
const { directory, calls, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
let authenticated = false
|
||||
options.runAgent = async (agent, prompt, workdir, runOptions) => {
|
||||
calls.push({ agent, prompt, workdir, runOptions })
|
||||
if (!authenticated) throw agentRuntimeError('LOCAL_AGENT_AUTH_REQUIRED', 'HTTP 401: expired credential')
|
||||
return { text: 'Recovered conversation', sessionRef: runOptions.sessionRef, outcome: 'completed' }
|
||||
}
|
||||
const workspace = new LocalWorkspace(options)
|
||||
await workspace.refreshAgents()
|
||||
const group = workspace.createGroup({ name: 'Auth continuity', agentKinds: ['hermes'], workdir: directory })
|
||||
const key = workspace.sessionKey(group.id, 'hermes')
|
||||
workspace.state.sessions[key] = 'hermes-auth-session'
|
||||
workspace.save()
|
||||
await workspace.sendMessage({ groupId: group.id, text: 'First attempt', targetKinds: ['hermes'] })
|
||||
assert.equal(workspace.state.sessions[key], 'hermes-auth-session')
|
||||
|
||||
authenticated = true
|
||||
workspace.markRuntimeCredential('hermes', 'ready')
|
||||
workspace.updateGroup(group.id, { agentKinds: ['hermes'] })
|
||||
await workspace.refreshAgents()
|
||||
await workspace.sendMessage({ groupId: group.id, text: 'Continue after login', targetKinds: ['hermes'] })
|
||||
assert.deepEqual(calls.map(call => call.runOptions.sessionRef), ['hermes-auth-session', 'hermes-auth-session'])
|
||||
assert.equal(workspace.state.sessions[key], 'hermes-auth-session')
|
||||
assert.equal(workspace.snapshot().messages.some(message => message.content === 'Recovered conversation'), true)
|
||||
})
|
||||
|
||||
test('automatic dialogue does not send an authentication recovery handoff to another Agent', async (t) => {
|
||||
const { directory, calls, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
@@ -4820,7 +4872,7 @@ test('automatic dialogue keeps a failed authentication slot out of later group c
|
||||
])
|
||||
const normalCodexCall = calls[0]
|
||||
assert.equal(normalCodexCall.runOptions.sessionRef, '')
|
||||
const codexSessionKey = workspace.sessionKey(group.id, 'codex', root.id)
|
||||
const codexSessionKey = workspace.sessionKey(group.id, 'codex')
|
||||
assert.equal(workspace.state.sessions[codexSessionKey], 'codex-task-session')
|
||||
const agentMessages = workspace.snapshot().messages.filter(message => message.role === 'agent')
|
||||
assert.equal(agentMessages.some(message => /repaired auth/.test(message.content)), false)
|
||||
@@ -4865,8 +4917,8 @@ test('automatic dialogue persists one failed 401 attempt and removes the Agent',
|
||||
])
|
||||
assert.deepEqual(workspace.getGroup(group.id).agentKinds, ['codex', 'workbuddy'])
|
||||
assert.equal(
|
||||
workspace.state.sessions[workspace.sessionKey(group.id, 'hermes', root.id)],
|
||||
undefined,
|
||||
workspace.state.sessions[workspace.sessionKey(group.id, 'hermes')],
|
||||
'unusable-hermes-session',
|
||||
)
|
||||
assert.equal(
|
||||
workspace.snapshot().agents.find(agent => agent.kind === 'hermes').credentialState,
|
||||
@@ -5117,7 +5169,7 @@ test('automatic dialogue reuses a session captured before a successful protocol
|
||||
assert.equal(calls[1].runOptions.sessionRef, 'kimi-created-before-failure')
|
||||
assert.deepEqual(workspace.getGroup(group.id).agentKinds, ['kimi', 'codex'])
|
||||
assert.equal(
|
||||
workspace.state.sessions[workspace.sessionKey(group.id, 'kimi', root.id)],
|
||||
workspace.state.sessions[workspace.sessionKey(group.id, 'kimi')],
|
||||
'kimi-created-before-failure',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ const {
|
||||
} = require('../../src/collaboration/outbound-payload.cjs')
|
||||
const { RunLedger } = require('../../src/runs/run-ledger.cjs')
|
||||
const { fixture } = require('../support/local-workspace-test-helpers.cjs')
|
||||
const { packedPromptContext } = require('../../src/workspace/local-workspace-context.cjs')
|
||||
|
||||
function jsonBlob(workspace, ref) {
|
||||
return JSON.parse(workspace.contentBlobStore.read(ref).toString('utf8'))
|
||||
@@ -64,6 +65,29 @@ function outboundPayload(prompt, transport = 'legacy') {
|
||||
})
|
||||
}
|
||||
|
||||
test('persistent group context watermark excludes already delivered peer turns', () => {
|
||||
const state = {
|
||||
groups: [{ id: 'group-watermark', conversationType: 'group' }],
|
||||
messages: [
|
||||
{ id: 'user-1', groupId: 'group-watermark', role: 'user', content: 'Initial task' },
|
||||
{ id: 'agent-a-1', groupId: 'group-watermark', role: 'agent', agentKind: 'codex', content: 'First answer' },
|
||||
{ id: 'agent-b-1', groupId: 'group-watermark', role: 'agent', agentKind: 'hermes', content: 'Peer answer' },
|
||||
{ id: 'user-2', groupId: 'group-watermark', role: 'user', content: 'Follow-up task' },
|
||||
{ id: 'agent-a-2', groupId: 'group-watermark', role: 'agent', agentKind: 'codex', content: 'Latest answer' },
|
||||
],
|
||||
}
|
||||
const packed = packedPromptContext({
|
||||
state,
|
||||
groupId: 'group-watermark',
|
||||
afterAgentKind: '',
|
||||
afterMessageId: 'agent-b-1',
|
||||
})
|
||||
assert.equal(packed.latestMessageId, 'agent-a-2')
|
||||
assert.equal(packed.recentText.includes('First answer'), false)
|
||||
assert.equal(packed.recentText.includes('Peer answer'), false)
|
||||
assert.equal(packed.recentText.includes('Latest answer'), true)
|
||||
})
|
||||
|
||||
test('explicit Connector selections persist immutable knowledge and citation sources without Vault paths', async (t) => {
|
||||
const { directory, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
@@ -265,7 +289,7 @@ test('Run inputs and exact outbound payloads remain immutable across mutation an
|
||||
attachmentBytes,
|
||||
)
|
||||
assert.deepEqual(delivery.sessionProvenance, {
|
||||
scope: 'task',
|
||||
scope: 'conversation',
|
||||
reuse: false,
|
||||
origin: 'created',
|
||||
originTaskId: task.id,
|
||||
@@ -523,7 +547,7 @@ test('Delivery comparison fails closed before a mismatched payload can dispatch'
|
||||
assert.equal(success, undefined)
|
||||
})
|
||||
|
||||
test('group Tasks reuse native Sessions only within the same automatic Task', async (t) => {
|
||||
test('group Tasks reuse native Sessions across automatic rounds and later Tasks', async (t) => {
|
||||
const { directory, calls, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
options.runAgent = async (agent, prompt, workdir, runOptions) => {
|
||||
@@ -561,31 +585,30 @@ test('group Tasks reuse native Sessions only within the same automatic Task', as
|
||||
})
|
||||
|
||||
assert.deepEqual(calls.map(call => call.runOptions.sessionRef), [
|
||||
'', '', 'codex-task-session', 'hermes-task-session', '',
|
||||
'', '', 'codex-task-session', 'hermes-task-session', 'codex-task-session',
|
||||
])
|
||||
const results = workspace.snapshot().messages.filter(message => message.role === 'agent')
|
||||
const codexResults = results.filter(message => message.agentKind === 'codex')
|
||||
const deliveries = codexResults.map(message => deliveryForMessage(workspace, message))
|
||||
assert.deepEqual(deliveries.map(delivery => delivery.sessionProvenance), [
|
||||
{
|
||||
scope: 'task', reuse: false, origin: 'created',
|
||||
scope: 'conversation', reuse: false, origin: 'created',
|
||||
originTaskId: automatic.threadRootId, inheritedTaskIds: [], completeness: 'complete',
|
||||
},
|
||||
{
|
||||
scope: 'task', reuse: true, origin: 'resumed',
|
||||
scope: 'conversation', reuse: true, origin: 'resumed',
|
||||
originTaskId: automatic.threadRootId, inheritedTaskIds: [], completeness: 'complete',
|
||||
},
|
||||
{
|
||||
scope: 'task', reuse: false, origin: 'created',
|
||||
originTaskId: codexResults[2].threadRootId,
|
||||
scope: 'conversation', reuse: true, origin: 'resumed',
|
||||
originTaskId: automatic.threadRootId,
|
||||
inheritedTaskIds: [], completeness: 'complete',
|
||||
},
|
||||
])
|
||||
assert.notEqual(codexResults[0].threadRootId, codexResults[2].threadRootId)
|
||||
assert.notEqual(
|
||||
workspace.sessionKey(group.id, 'codex', codexResults[0].threadRootId),
|
||||
workspace.sessionKey(group.id, 'codex', codexResults[2].threadRootId),
|
||||
)
|
||||
assert.equal(Object.keys(workspace.state.sessions).length, 2)
|
||||
assert.deepEqual(workspace.state.sessionMeta[workspace.sessionKey(group.id, 'codex')].inheritedTaskIds,
|
||||
[codexResults[2].threadRootId])
|
||||
})
|
||||
|
||||
test('continuation traces describe only the exact outbound context and fingerprints', async (t) => {
|
||||
@@ -708,7 +731,7 @@ test('direct Session deliveries expose bounded Task ancestry across messages', a
|
||||
)
|
||||
})
|
||||
|
||||
test('legacy conversation Sessions are discarded before a new group Task', async (t) => {
|
||||
test('legacy conversation Sessions retain unknown provenance across group Tasks', async (t) => {
|
||||
const { directory, calls, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
options.runAgent = async (agent, prompt, workdir, runOptions) => {
|
||||
@@ -731,25 +754,25 @@ test('legacy conversation Sessions are discarded before a new group Task', async
|
||||
await workspace.sendMessage({ groupId: group.id, text: 'Migrate once', targetKinds: ['codex'] })
|
||||
await workspace.sendMessage({ groupId: group.id, text: 'Do not reuse it again', targetKinds: ['codex'] })
|
||||
|
||||
assert.deepEqual(calls.map(call => call.runOptions.sessionRef), ['', ''])
|
||||
assert.deepEqual(calls.map(call => call.runOptions.sessionRef), ['codex-legacy-global', 'codex-legacy-global'])
|
||||
const results = workspace.snapshot().messages.filter(message => message.role === 'agent')
|
||||
assert.deepEqual(deliveryForMessage(workspace, results[0]).sessionProvenance, {
|
||||
scope: 'task',
|
||||
reuse: false,
|
||||
origin: 'created',
|
||||
originTaskId: results[0].threadRootId,
|
||||
scope: 'unknown-legacy',
|
||||
reuse: true,
|
||||
origin: 'unknown-legacy',
|
||||
originTaskId: null,
|
||||
inheritedTaskIds: [],
|
||||
completeness: 'complete',
|
||||
completeness: 'unknown-legacy',
|
||||
})
|
||||
assert.deepEqual(deliveryForMessage(workspace, results[1]).sessionProvenance, {
|
||||
scope: 'task',
|
||||
reuse: false,
|
||||
origin: 'created',
|
||||
originTaskId: results[1].threadRootId,
|
||||
scope: 'unknown-legacy',
|
||||
reuse: true,
|
||||
origin: 'unknown-legacy',
|
||||
originTaskId: null,
|
||||
inheritedTaskIds: [],
|
||||
completeness: 'complete',
|
||||
completeness: 'unknown-legacy',
|
||||
})
|
||||
assert.equal(Object.hasOwn(workspace.state.sessions, legacyKey), false)
|
||||
assert.equal(Object.hasOwn(workspace.state.sessions, legacyKey), true)
|
||||
})
|
||||
|
||||
test('Session ref and provenance persistence rolls back together when saving fails', async (t) => {
|
||||
|
||||
@@ -1260,7 +1260,7 @@ test('terminal Agent traces hand compact partial evidence to the next Agent only
|
||||
assert.doesNotMatch(afterCodex, /Partial conclusion for Hermes/)
|
||||
})
|
||||
|
||||
test('Harness rotates an over-budget native session while retaining compressed continuity', async (t) => {
|
||||
test('Harness retains a group native session beyond the former rotation budget', async (t) => {
|
||||
const { directory, calls, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
options.runAgent = async (agent, prompt, workdir, runOptions) => {
|
||||
@@ -1287,19 +1287,19 @@ test('Harness rotates an over-budget native session while retaining compressed c
|
||||
targetKinds: ['codex'],
|
||||
})
|
||||
|
||||
assert.equal(calls[0].runOptions.sessionRef, '')
|
||||
assert.equal(calls[0].runOptions.sessionRef, 'old-session')
|
||||
assert.match(calls[0].prompt, /Previous conclusion/)
|
||||
const snapshot = workspace.snapshot()
|
||||
const currentUser = snapshot.messages.find(message => (
|
||||
message.role === 'user' && message.content === 'Continue with a fresh context'
|
||||
))
|
||||
const key = workspace.sessionKey(group.id, 'codex', currentUser.id)
|
||||
const key = workspace.sessionKey(group.id, 'codex')
|
||||
const trace = snapshot.messages.at(-1).trace
|
||||
assert.equal(trace.context.sessionRotated, true)
|
||||
assert.notEqual(trace.context.sessionRotated, true)
|
||||
assert.deepEqual(trace.sourceMessageIds, [currentUser.id, oldUser.id, previousAgent.id])
|
||||
assert.equal(trace.context.includedCount, trace.sourceMessageIds.length)
|
||||
assert.equal(workspace.state.sessions[key], 'new-session')
|
||||
assert.equal(workspace.state.sessionMeta[key].turns, 1)
|
||||
assert.equal(workspace.state.sessionMeta[key].turns, 19)
|
||||
assert.equal(workspace.state.sessionMeta[key].estimatedChars > calls[0].prompt.length, true)
|
||||
})
|
||||
|
||||
@@ -1341,7 +1341,7 @@ test('Session rotation restores the previous ref and provenance when saving fail
|
||||
assert.equal(calls.length, 0)
|
||||
})
|
||||
|
||||
test('Hermes starts task-scoped persistent ACP while a frozen Skill stays prompt-only', async (t) => {
|
||||
test('Hermes retains group-scoped persistent ACP while a frozen Skill stays prompt-only', async (t) => {
|
||||
const { directory, calls, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
options.detectAgents = async () => [{
|
||||
@@ -1379,8 +1379,8 @@ test('Hermes starts task-scoped persistent ACP while a frozen Skill stays prompt
|
||||
}],
|
||||
})
|
||||
|
||||
assert.equal(calls[0].runOptions.sessionRef, '')
|
||||
assert.equal(calls[0].runOptions.sessionTransport, '')
|
||||
assert.equal(calls[0].runOptions.sessionRef, 'hermes-acp-session')
|
||||
assert.equal(calls[0].runOptions.sessionTransport, 'acp')
|
||||
assert.equal(calls[0].runOptions.hermesAcpAvailable, true)
|
||||
assert.equal(typeof calls[0].runOptions.acpPersistenceKey, 'string')
|
||||
assert.equal(calls[0].runOptions.skills, undefined)
|
||||
@@ -1390,13 +1390,13 @@ test('Hermes starts task-scoped persistent ACP while a frozen Skill stays prompt
|
||||
const currentUser = snapshot.messages.find(message => (
|
||||
message.role === 'user' && message.content === 'Continue with the selected skill'
|
||||
))
|
||||
const key = workspace.sessionKey(group.id, 'hermes', currentUser.id)
|
||||
const key = workspace.sessionKey(group.id, 'hermes')
|
||||
assert.equal(calls[0].runOptions.acpPersistenceKey, key)
|
||||
assert.equal(workspace.state.sessions[key], 'hermes-acp-session')
|
||||
assert.equal(workspace.state.sessionMeta[key].transport, 'acp')
|
||||
assert.equal(workspace.state.sessionMeta[key].turns, 1)
|
||||
assert.equal(workspace.state.sessionMeta[key].turns, 3)
|
||||
const trace = snapshot.messages.at(-1).trace
|
||||
assert.equal(trace.context.sessionRotated, true)
|
||||
assert.notEqual(trace.context.sessionRotated, true)
|
||||
assert.deepEqual(trace.sourceMessageIds, [currentUser.id, oldUser.id, previousAgent.id])
|
||||
assert.equal(trace.context.includedCount, trace.sourceMessageIds.length)
|
||||
})
|
||||
@@ -1413,6 +1413,7 @@ test('Hermes replaces an unavailable stored ACP runtime with rebuilt context', a
|
||||
}]
|
||||
options.runAgent = async (agent, prompt, workdir, runOptions) => {
|
||||
calls.push({ agent, prompt, workdir, runOptions })
|
||||
if (calls.length === 1) throw new Error('LOCAL_AGENT_SESSION_INVALID')
|
||||
await runOptions.onSessionRef('hermes-recovered-session', { transport: 'acp' })
|
||||
return { text: 'Recovered conclusion', sessionRef: 'hermes-recovered-session' }
|
||||
}
|
||||
@@ -1436,8 +1437,10 @@ test('Hermes replaces an unavailable stored ACP runtime with rebuilt context', a
|
||||
targetKinds: ['hermes'],
|
||||
})
|
||||
|
||||
assert.equal(calls[0].runOptions.sessionRef, '')
|
||||
assert.equal(calls[0].runOptions.sessionTransport, '')
|
||||
assert.equal(calls[0].runOptions.sessionRef, 'hermes-stale-acp-session')
|
||||
assert.equal(calls[0].runOptions.sessionTransport, 'acp')
|
||||
assert.equal(calls[1].runOptions.sessionRef, '')
|
||||
assert.equal(calls[1].runOptions.sessionTransport, '')
|
||||
assert.equal(calls[0].runOptions.hermesAcpAvailable, true)
|
||||
assert.equal(typeof calls[0].runOptions.acpPersistenceKey, 'string')
|
||||
assert.match(calls[0].prompt, /Previous Hermes conclusion/)
|
||||
@@ -1446,7 +1449,7 @@ test('Hermes replaces an unavailable stored ACP runtime with rebuilt context', a
|
||||
const currentUser = snapshot.messages.find(message => (
|
||||
message.role === 'user' && message.content === 'Continue after recovering the session'
|
||||
))
|
||||
const key = workspace.sessionKey(group.id, 'hermes', currentUser.id)
|
||||
const key = workspace.sessionKey(group.id, 'hermes')
|
||||
assert.equal(calls[0].runOptions.acpPersistenceKey, key)
|
||||
assert.equal(workspace.state.sessions[key], 'hermes-recovered-session')
|
||||
assert.equal(workspace.state.sessionMeta[key].transport, 'acp')
|
||||
@@ -1457,7 +1460,7 @@ test('Hermes replaces an unavailable stored ACP runtime with rebuilt context', a
|
||||
assert.equal(trace.context.includedCount, trace.sourceMessageIds.length)
|
||||
})
|
||||
|
||||
test('Harness discards an over-budget conversation OpenClaw Session for a new Task key', async (t) => {
|
||||
test('Harness retains an over-budget conversation OpenClaw Session for a new Task', async (t) => {
|
||||
const { directory, calls, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
options.runAgent = async (agent, prompt, workdir, runOptions) => {
|
||||
@@ -1489,20 +1492,18 @@ test('Harness discards an over-budget conversation OpenClaw Session for a new Ta
|
||||
const currentUser = snapshot.messages.find(message => (
|
||||
message.role === 'user' && message.content === 'Continue with bounded context'
|
||||
))
|
||||
const key = workspace.sessionKey(group.id, 'openclaw', currentUser.id)
|
||||
const currentTaskSessionRef = workspace.openClawSessionRef(group, '', currentUser.id)
|
||||
assert.notEqual(calls[0].runOptions.sessionRef, previousSessionRef)
|
||||
assert.equal(calls[0].runOptions.sessionRef, currentTaskSessionRef)
|
||||
const key = workspace.sessionKey(group.id, 'openclaw')
|
||||
assert.equal(calls[0].runOptions.sessionRef, previousSessionRef)
|
||||
assert.match(calls[0].prompt, /Prior OpenClaw conclusion/)
|
||||
const trace = snapshot.messages.at(-1).trace
|
||||
assert.equal(trace.context.sessionRotated, true)
|
||||
assert.notEqual(trace.context.sessionRotated, true)
|
||||
assert.deepEqual(trace.sourceMessageIds, [currentUser.id, oldUser.id, previousAgent.id])
|
||||
assert.equal(trace.context.includedCount, trace.sourceMessageIds.length)
|
||||
assert.equal(workspace.state.sessions[key], calls[0].runOptions.sessionRef)
|
||||
assert.equal(workspace.state.sessionMeta[key].turns, 1)
|
||||
assert.equal(workspace.state.sessionMeta[key].turns, 19)
|
||||
})
|
||||
|
||||
test('legacy conversation Sessions are not resumed by a new group Task', async (t) => {
|
||||
test('legacy conversation Sessions resume for a new group Task', async (t) => {
|
||||
const { directory, calls, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const workspace = new LocalWorkspace(options)
|
||||
@@ -1519,9 +1520,9 @@ test('legacy conversation Sessions are not resumed by a new group Task', async (
|
||||
const task = workspace.snapshot().messages.find(message => (
|
||||
message.role === 'user' && message.content === 'Resume safely'
|
||||
))
|
||||
const key = workspace.sessionKey(group.id, 'codex', task.id)
|
||||
assert.equal(calls[0].runOptions.sessionRef, '')
|
||||
assert.equal(Object.hasOwn(workspace.state.sessions, legacyKey), false)
|
||||
const key = workspace.sessionKey(group.id, 'codex')
|
||||
assert.equal(calls[0].runOptions.sessionRef, 'legacy-codex-session')
|
||||
assert.equal(Object.hasOwn(workspace.state.sessions, legacyKey), true)
|
||||
assert.equal(workspace.state.sessionMeta[key].turns, 1)
|
||||
assert.equal(workspace.state.sessionMeta[key].estimatedChars > 0, true)
|
||||
})
|
||||
@@ -1592,7 +1593,7 @@ test('invalid Session recovery restores the reused ref when saving the rebuild f
|
||||
name: 'Atomic invalid Session rebuild', agentKinds: ['codex'], workdir: directory,
|
||||
})
|
||||
const task = workspace.addMessage(group.id, 'user', 'Recover atomically')
|
||||
const key = workspace.sessionKey(group.id, 'codex', task.id)
|
||||
const key = workspace.sessionKey(group.id, 'codex')
|
||||
workspace.state.sessions[key] = 'codex-before-rebuild'
|
||||
workspace.state.sessionMeta[key] = {
|
||||
turns: 2,
|
||||
@@ -3480,6 +3481,36 @@ test('the Agent watchdog covers output capture and import phases', async (t) =>
|
||||
}
|
||||
})
|
||||
|
||||
test('reset before migration revokes conversation, thread and legacy task sessions only for its Agent', async (t) => {
|
||||
const { directory, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const workspace = new LocalWorkspace(options)
|
||||
const group = { id: 'reset-group', conversationType: 'group' }
|
||||
const taskIds = ['legacy-task', 'legacy/task-with-hashed-key']
|
||||
workspace.state.messages.push(...taskIds.map(id => ({ id, groupId: group.id, role: 'user', content: 'Prior task' })))
|
||||
const revokedKeys = [
|
||||
workspace.sessionKey(group.id, 'codex'),
|
||||
...taskIds.map(id => workspace.sessionKey(group.id, 'codex', id)),
|
||||
`${group.id}:codex:thread:legacy-root`,
|
||||
`${group.id}:task:orphan-task:codex`,
|
||||
workspace.sessionKey(group.id, 'codex', 'pending/task'),
|
||||
]
|
||||
assert.match(revokedKeys[2], /^session:/)
|
||||
const retainedKeys = [workspace.sessionKey(group.id, 'hermes', taskIds[0]), workspace.sessionKey('other-group', 'codex', taskIds[0])]
|
||||
for (const key of [...revokedKeys, ...retainedKeys]) {
|
||||
workspace.state.sessions[key] = 'legacy-native-session'
|
||||
workspace.state.sessionMeta[key] = { turns: 2, estimatedChars: 100 }
|
||||
}
|
||||
workspace.resetAgentSession(group, 'codex', true, 'pending/task')
|
||||
for (const key of revokedKeys) {
|
||||
assert.equal(workspace.state.sessions[key], undefined, key)
|
||||
assert.equal(workspace.state.sessionMeta[key], undefined, key)
|
||||
}
|
||||
for (const key of retainedKeys) assert.equal(workspace.state.sessions[key], 'legacy-native-session', key)
|
||||
assert.equal(workspace.sessionState(group, 'codex', taskIds[0], taskIds[0]).sessionRef, '')
|
||||
assert.equal(workspace.sessionState(group, 'codex', taskIds[1], taskIds[1]).sessionRef, '')
|
||||
})
|
||||
|
||||
test('session references stay opaque and OpenClaw group scopes do not collide', async (t) => {
|
||||
const { directory, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { LocalWorkspace } = require('../../src/workspace/local-workspace.cjs')
|
||||
const { RunLedger } = require('../../src/runs/run-ledger.cjs')
|
||||
const { fixture } = require('../support/local-workspace-test-helpers.cjs')
|
||||
|
||||
const requests = [
|
||||
'Do not generate an image. Explain the API contract only.',
|
||||
'Create a test for the video generation API; no media service calls.',
|
||||
'Please quote the phrase "generate an audio clip" without executing it.',
|
||||
'请不要生成图片,只讨论接口设计。',
|
||||
'Generate an image using your own available tools.',
|
||||
]
|
||||
|
||||
for (const workflow of ['direct', 'manual', 'legacy-auto', 'agent-led', 'sequential']) {
|
||||
test(`${workflow} leaves media intent and execution to the selected Agent`, async t => {
|
||||
const { directory, options } = fixture()
|
||||
const calls = []
|
||||
let mediaCalls = 0
|
||||
const natural = ['agent-led', 'sequential'].includes(workflow)
|
||||
const ledger = new RunLedger({ storagePath: path.join(directory, 'ledger.json') })
|
||||
const workspace = new LocalWorkspace({
|
||||
...options,
|
||||
runLedger: ledger,
|
||||
naturalAgentResponses: natural,
|
||||
generateMedia: async () => { mediaCalls += 1; throw new Error('UNEXPECTED_MEDIA_DISPATCH') },
|
||||
runAgent: async (agent, prompt) => {
|
||||
calls.push({ kind: agent.kind, prompt })
|
||||
const text = 'The configured Providers do not offer the required media model.'
|
||||
const receipt = natural ? `\n[[MELDWORK_COLLABORATION:${JSON.stringify({
|
||||
summary: text,
|
||||
taskDecision: { status: 'completed', reason: 'The requested explanation is delivered.',
|
||||
deliverables: ['Explanation in this response.'], nextKinds: [] },
|
||||
})}]]` : ''
|
||||
return { outcome: 'completed', text: text + receipt }
|
||||
},
|
||||
})
|
||||
t.after(async () => {
|
||||
await workspace.stopAll()
|
||||
fs.rmSync(directory, { recursive: true, force: true })
|
||||
})
|
||||
await workspace.refreshAgents()
|
||||
const group = workspace.createGroup({
|
||||
name: 'Native execution', workdir: directory, allowWrite: true,
|
||||
agentKinds: ['hermes'],
|
||||
...(workflow === 'direct' ? { conversationType: 'direct', directAgentKind: 'hermes' } : {}),
|
||||
})
|
||||
for (const text of requests) {
|
||||
const before = calls.length
|
||||
await workspace.sendMessage({
|
||||
groupId: group.id, text, targetKinds: ['hermes'],
|
||||
mode: ['direct', 'manual'].includes(workflow) ? 'manual' : 'auto',
|
||||
maxRounds: natural ? 3 : 1,
|
||||
...(natural ? { protocol: 'v4', discussionStyle: workflow } : {}),
|
||||
})
|
||||
await workspace.activeRuns.get(group.id)?.promise
|
||||
assert.ok(calls.length > before)
|
||||
assert.ok(calls.slice(before).every(call => call.kind === 'hermes' && call.prompt.includes(text)))
|
||||
}
|
||||
assert.equal(mediaCalls, 0)
|
||||
assert.ok(calls.every(call => !/Meldwork generated|shared media generator was unavailable/.test(call.prompt)))
|
||||
const replies = workspace.snapshot().messages.filter(message => message.role === 'agent')
|
||||
assert.ok(replies.length >= requests.length)
|
||||
assert.ok(replies.every(message => message.content === 'The configured Providers do not offer the required media model.'))
|
||||
const root = workspace.state.messages.find(message => message.role === 'user')
|
||||
const recovered = await workspace.autoRunner.automaticContext(group, { targetKinds: ['hermes'] }, root.id)
|
||||
assert.equal(Object.hasOwn(recovered, 'rootMediaRequest'), false)
|
||||
})
|
||||
}
|
||||
@@ -39,6 +39,191 @@ 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('direct conversations do not rotate their session after the default turn budget', 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({ prompt, runOptions })
|
||||
const sessionRef = runOptions.sessionRef || 'pi-long-session'
|
||||
await runOptions.onSessionRef(sessionRef)
|
||||
return { text: 'reply', sessionRef, outcome: 'completed' }
|
||||
}
|
||||
const workspace = new LocalWorkspace(options)
|
||||
await workspace.refreshAgents()
|
||||
const group = workspace.createGroup({
|
||||
name: 'Long direct', agentKinds: ['pi'], directAgentKind: 'pi',
|
||||
conversationType: 'direct', workdir: directory,
|
||||
})
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
await workspace.sendMessage({ groupId: group.id, text: `message-${index}` })
|
||||
}
|
||||
assert.equal(calls.length, 20)
|
||||
assert.equal(calls.filter(call => call.runOptions.sessionRef === '').length, 1)
|
||||
assert.equal(calls.slice(1).every(call => call.runOptions.sessionRef === 'pi-long-session'), true)
|
||||
})
|
||||
|
||||
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 }))
|
||||
options.detectAgents = async () => [
|
||||
{ kind: 'codex', name: 'Codex CLI', executable: '/tmp/codex', version: '1' },
|
||||
{ kind: 'opencode', name: 'OpenCode CLI', executable: '/tmp/opencode', version: '1' },
|
||||
]
|
||||
options.runAgent = async (agent, prompt, workdir, runOptions) => {
|
||||
calls.push({ agent, prompt, workdir, runOptions })
|
||||
const sessionRef = runOptions.sessionRef || `${agent.kind}-acp-session`
|
||||
if (agent.kind === 'opencode') {
|
||||
await runOptions.onSessionRef(sessionRef, { transport: 'acp' })
|
||||
}
|
||||
return { text: `reply ${calls.length}`, sessionRef, outcome: 'completed' }
|
||||
}
|
||||
const workspace = new LocalWorkspace(options)
|
||||
await workspace.refreshAgents()
|
||||
const group = workspace.createGroup({
|
||||
name: 'OpenCode ACP group', agentKinds: ['codex', 'opencode'], workdir: directory,
|
||||
})
|
||||
|
||||
await workspace.sendMessage({ groupId: group.id, text: 'first', mode: 'auto', maxRounds: 2 })
|
||||
await workspace.activeRuns.get(group.id)?.promise
|
||||
|
||||
const opencodeCalls = calls.filter(call => call.agent.kind === 'opencode')
|
||||
assert.deepEqual(opencodeCalls.map(call => call.runOptions.sessionRef), ['', 'opencode-acp-session'])
|
||||
assert.deepEqual(opencodeCalls.map(call => call.runOptions.sessionTransport), ['', 'acp'])
|
||||
assert.equal(typeof opencodeCalls[0].runOptions.acpPersistenceKey, 'string')
|
||||
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' })
|
||||
}
|
||||
const taskDecision = agent.kind === 'hermes' ? `\n[[MELDWORK_COLLABORATION:${JSON.stringify({
|
||||
summary: 'Continue the session-continuity exercise.',
|
||||
taskDecision: {
|
||||
status: 'continue', reason: 'Continue the requested rounds until the configured round budget is reached.',
|
||||
deliverables: [],
|
||||
},
|
||||
})}]]` : ''
|
||||
return { text: `${agent.kind} round response${taskDecision}`, 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 }))
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
const { LocalWorkspaceAutoRunner } = require('../../src/workspace/local-workspace-auto-runner.cjs')
|
||||
const { LocalWorkspace } = require('../../src/workspace/local-workspace.cjs')
|
||||
const { RunLedger } = require('../../src/runs/run-ledger.cjs')
|
||||
const { fixture } = require('../support/local-workspace-test-helpers.cjs')
|
||||
|
||||
function deliveryFixture() {
|
||||
const messages = []
|
||||
const runner = Object.create(LocalWorkspaceAutoRunner.prototype)
|
||||
runner.state = () => ({ messages })
|
||||
runner.v4NaturalMessageMatchesBinding = () => true
|
||||
runner.v4SanitizeDeliveryText = text => text
|
||||
runner.checkpointOrchestration = (_group, controller, update) => {
|
||||
controller.orchestration = structuredClone({ ...controller.orchestration, ...update })
|
||||
}
|
||||
const group = { id: 'group' }
|
||||
const controller = { runId: 'run', orchestration: { snapshotHash: '3'.repeat(64) } }
|
||||
const binding = { hasSession: true, sessionRefHash: '1'.repeat(64), sessionProvenanceHash: '2'.repeat(64) }
|
||||
const add = (text, kind = 'codex') => {
|
||||
const id = `message-${messages.length}`
|
||||
messages.push({ id, content: text, role: 'agent', agentKind: kind, groupId: group.id, threadRootId: 'thread',
|
||||
trace: { round: messages.length + 1, phase: 'discussion', context: { operationId: `operation-${id}` } } })
|
||||
return id
|
||||
}
|
||||
const prepare = (current = binding, kind = 'hermes') => runner.v4NaturalDeliveryPrompt(
|
||||
group, controller, 'thread', kind, { operationId: 'dispatch' }, current, text => `TASK\n${text}`,
|
||||
)
|
||||
const settle = (built, status = 'acknowledged', current = binding) => runner.v4SetDeliveryStatus(
|
||||
group, controller, built.delivery, status, current,
|
||||
)
|
||||
return { runner, group, controller, binding, messages, add, prepare, settle }
|
||||
}
|
||||
|
||||
test('Natural delivery omits only unchanged complete messages acknowledged in this native session', () => {
|
||||
const f = deliveryFixture()
|
||||
f.add('FIRST_EVIDENCE')
|
||||
const first = f.prepare()
|
||||
assert.match(first.prompt, /FIRST_EVIDENCE/)
|
||||
const retry = f.prepare()
|
||||
assert.match(retry.prompt, /FIRST_EVIDENCE/, 'Preparing is not acknowledgement')
|
||||
f.settle(retry)
|
||||
assert.doesNotMatch(f.prepare().prompt, /FIRST_EVIDENCE/)
|
||||
f.add('SECOND_EVIDENCE')
|
||||
const second = f.prepare()
|
||||
assert.doesNotMatch(second.prompt, /FIRST_EVIDENCE/)
|
||||
assert.match(second.prompt, /SECOND_EVIDENCE/)
|
||||
f.settle(second)
|
||||
assert.equal(f.controller.orchestration.deliveryState[0].sourceMessages.length, 2)
|
||||
assert.doesNotMatch(f.prepare().prompt, /FIRST_EVIDENCE|SECOND_EVIDENCE/)
|
||||
f.messages[0].content = 'CORRECTED_EVIDENCE'
|
||||
assert.match(f.prepare().prompt, /CORRECTED_EVIDENCE/)
|
||||
})
|
||||
|
||||
test('Natural delivery acknowledgements are scoped to recipient, provenance, snapshot, and session', () => {
|
||||
const f = deliveryFixture()
|
||||
f.add('SCOPED_EVIDENCE')
|
||||
f.settle(f.prepare())
|
||||
const acknowledged = structuredClone(f.controller.orchestration)
|
||||
for (const changed of [
|
||||
{ hasSession: false }, { sessionRotated: true },
|
||||
{ sessionRefHash: '4'.repeat(64) }, { sessionProvenanceHash: '5'.repeat(64) },
|
||||
]) {
|
||||
f.controller.orchestration = structuredClone(acknowledged)
|
||||
assert.match(f.prepare({ ...f.binding, ...changed }).prompt, /SCOPED_EVIDENCE/)
|
||||
}
|
||||
f.controller.orchestration = structuredClone(acknowledged)
|
||||
assert.match(f.prepare(f.binding, 'codex').prompt, /SCOPED_EVIDENCE/)
|
||||
f.controller.orchestration = structuredClone(acknowledged)
|
||||
f.controller.orchestration.snapshotHash = '6'.repeat(64)
|
||||
assert.match(f.prepare().prompt, /SCOPED_EVIDENCE/)
|
||||
})
|
||||
|
||||
test('Natural uncertain delivery is resent, and partial or omitted entries are never acknowledged', () => {
|
||||
const f = deliveryFixture()
|
||||
f.add('RETRY_EVIDENCE')
|
||||
f.settle(f.prepare(), 'uncertain')
|
||||
assert.match(f.prepare().prompt, /RETRY_EVIDENCE/)
|
||||
f.add(`LONG_START\n${'x'.repeat(100000)}\nLONG_END`)
|
||||
const partial = f.prepare()
|
||||
assert.match(partial.prompt, /Middle of this turn omitted/)
|
||||
assert.equal(partial.delivery.entries[0].sourceMessages.length, 1)
|
||||
f.settle(partial)
|
||||
const repeated = f.prepare()
|
||||
assert.doesNotMatch(repeated.prompt, /RETRY_EVIDENCE/)
|
||||
assert.match(repeated.prompt, /LONG_START/)
|
||||
assert.equal(repeated.delivery, null)
|
||||
assert.ok(repeated.prompt.length <= 48005)
|
||||
})
|
||||
|
||||
test('A native session change at completion cannot inherit acknowledgements for unsent earlier input', () => {
|
||||
const f = deliveryFixture()
|
||||
f.add('EARLIER_INPUT')
|
||||
f.settle(f.prepare())
|
||||
f.add('CURRENT_INPUT')
|
||||
const current = f.prepare()
|
||||
const rotated = { ...f.binding, sessionRefHash: '7'.repeat(64) }
|
||||
f.settle(current, 'acknowledged', rotated)
|
||||
const rebuilt = f.prepare(rotated)
|
||||
assert.match(rebuilt.prompt, /EARLIER_INPUT/)
|
||||
assert.doesNotMatch(rebuilt.prompt, /CURRENT_INPUT/)
|
||||
})
|
||||
|
||||
test('Natural acknowledged message records remain bounded and contain no conversation text', () => {
|
||||
const f = deliveryFixture()
|
||||
for (let index = 0; index < 101; index += 1) f.add(`EVIDENCE_${index}`)
|
||||
const packed = f.prepare()
|
||||
assert.equal(packed.delivery.entries[0].sourceMessages.length, 100)
|
||||
assert.doesNotMatch(JSON.stringify(packed.delivery.entries), /EVIDENCE_/)
|
||||
f.settle(packed)
|
||||
const remaining = f.prepare()
|
||||
assert.match(remaining.prompt, /EVIDENCE_0\n?$/)
|
||||
f.settle(remaining)
|
||||
assert.equal(f.controller.orchestration.deliveryState[0].sourceMessages.length, 100)
|
||||
})
|
||||
|
||||
for (const staleSession of [false, true]) {
|
||||
test(`Natural sequential calls preserve task and unseen evidence with stale session=${staleSession}`, async (t) => {
|
||||
const { directory, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const ledger = new RunLedger({ storagePath: path.join(directory, 'run-ledger.json') })
|
||||
const calls = []
|
||||
const counts = new Map()
|
||||
let invalidated = false
|
||||
const workspace = new LocalWorkspace({
|
||||
...options, runLedger: ledger, naturalAgentResponses: true,
|
||||
runAgent: async (agent, prompt, _workdir, runOptions) => {
|
||||
const count = (counts.get(agent.kind) || 0) + 1
|
||||
counts.set(agent.kind, count)
|
||||
calls.push({ kind: agent.kind, prompt, sessionRef: runOptions.sessionRef, count })
|
||||
if (staleSession && agent.kind === 'codex' && count === 3) {
|
||||
invalidated = true
|
||||
throw new Error('LOCAL_AGENT_SESSION_INVALID')
|
||||
}
|
||||
const text = count === 1 ? `PROPOSAL_${agent.kind}` : `CONTRIBUTION_${agent.kind}_${count}`
|
||||
return {
|
||||
outcome: 'completed', sessionRef: runOptions.sessionRef || `${agent.kind}-${invalidated ? 'new' : 'initial'}`,
|
||||
text: `${text}\n[[MELDWORK_COLLABORATION:${JSON.stringify({ summary: text,
|
||||
taskDecision: { status: 'continue', reason: text, deliverables: [] },
|
||||
})}]]`,
|
||||
}
|
||||
},
|
||||
})
|
||||
await workspace.refreshAgents()
|
||||
const group = workspace.createGroup({ name: 'Incremental context', agentKinds: ['codex', 'hermes'], workdir: directory, allowWrite: false })
|
||||
await workspace.sendMessage({
|
||||
groupId: group.id, text: 'AUTHORITATIVE_TASK', mode: 'auto', protocol: 'v4',
|
||||
discussionStyle: 'sequential', targetKinds: group.agentKinds, maxRounds: 3,
|
||||
})
|
||||
const controller = workspace.activeRuns.get(group.id)
|
||||
await controller.promise
|
||||
assert.equal(ledger.get(controller.runId).status, 'round-limit')
|
||||
const codex = calls.filter(call => call.kind === 'codex')
|
||||
assert.match(codex[1].prompt, /PROPOSAL_hermes/)
|
||||
assert.doesNotMatch(codex[2].prompt, /PROPOSAL_hermes|PROPOSAL_codex/)
|
||||
assert.match(codex[2].prompt, /CONTRIBUTION_hermes_2/)
|
||||
assert.ok(calls.every(call => call.prompt.includes('AUTHORITATIVE_TASK')))
|
||||
if (staleSession) {
|
||||
assert.equal(codex.length, 4)
|
||||
assert.equal(codex[3].sessionRef, '')
|
||||
assert.match(codex[3].prompt, /PROPOSAL_hermes|PROPOSAL_codex/)
|
||||
assert.match(codex[3].prompt, /CONTRIBUTION_hermes_2/)
|
||||
}
|
||||
const reopened = new RunLedger({ storagePath: path.join(directory, 'run-ledger.json') })
|
||||
assert.deepEqual(reopened.get(controller.runId).orchestration.deliveryState,
|
||||
ledger.get(controller.runId).orchestration.deliveryState)
|
||||
assert.ok(reopened.get(controller.runId).orchestration.deliveryState.some(entry => entry.sourceMessages?.length))
|
||||
})
|
||||
}
|
||||
|
||||
test('Natural completion with an absolute deliverable path persists its receipt and terminal state', async (t) => {
|
||||
const { directory, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const ledger = new RunLedger({ storagePath: path.join(directory, 'run-ledger.json') })
|
||||
const decision = {
|
||||
status: 'completed', reason: `Verified ${path.join(directory, 'result.txt')}`,
|
||||
deliverables: [`File ${path.join(directory, 'result.txt')} contains the requested result.`],
|
||||
}
|
||||
let calls = 0
|
||||
const workspace = new LocalWorkspace({
|
||||
...options, runLedger: ledger, naturalAgentResponses: true,
|
||||
runAgent: async () => ({ outcome: 'completed', sessionRef: 'native-path-session',
|
||||
text: ++calls === 1 ? 'Initial proposal.' : `Verified result.txt.\n[[MELDWORK_COLLABORATION:${JSON.stringify({ summary: 'Verified result.', taskDecision: decision })}]]`,
|
||||
}),
|
||||
})
|
||||
await workspace.refreshAgents()
|
||||
const group = workspace.createGroup({ name: 'File completion', agentKinds: ['codex'], workdir: directory, allowWrite: false })
|
||||
await workspace.sendMessage({ groupId: group.id, text: 'Verify the result.', mode: 'auto', protocol: 'v4', discussionStyle: 'agent-led', targetKinds: ['codex'], maxRounds: 3 })
|
||||
const controller = workspace.activeRuns.get(group.id)
|
||||
await controller.promise
|
||||
const record = ledger.get(controller.runId)
|
||||
assert.equal(record.status, 'completed')
|
||||
const receipt = record.orchestration.slots[0].resultRefs.workflowOutcomeRefs.at(-1).receipt
|
||||
assert.equal(receipt.taskDecision.reason, 'Verified [path]')
|
||||
assert.deepEqual(record.agentRuns.at(-1).context.taskDecision, decision)
|
||||
})
|
||||