@piagent/platform 1.48.4 → 1.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/docs/command-reference-vietnamese.md +2 -2
- package/docs/distribution-standard.md +6 -6
- package/docs/operator-manual-vietnamese.md +7 -7
- package/docs/publishing-for-teams.md +2 -0
- package/docs/release-install-policy.md +2 -2
- package/docs/team-onboarding.md +2 -2
- package/docs-site/architecture.html +1 -1
- package/docs-site/assets/search-en.json +1 -1
- package/docs-site/assets/search-vi.json +1 -1
- package/docs-site/benchmark.html +1 -1
- package/docs-site/commands.html +1 -1
- package/docs-site/context-engine.html +1 -1
- package/docs-site/ecosystem.html +1 -1
- package/docs-site/en/architecture.html +1 -1
- package/docs-site/en/benchmark.html +1 -1
- package/docs-site/en/commands.html +1 -1
- package/docs-site/en/context-engine.html +1 -1
- package/docs-site/en/ecosystem.html +1 -1
- package/docs-site/en/index.html +15 -15
- package/docs-site/en/mcp-approval.html +1 -1
- package/docs-site/en/mcp-auth.html +1 -1
- package/docs-site/en/mcp-servers.html +1 -1
- package/docs-site/en/mcp.html +1 -1
- package/docs-site/en/permissions.html +1 -1
- package/docs-site/en/profiles.html +1 -1
- package/docs-site/en/quickstart.html +1 -1
- package/docs-site/en/runtime.html +1 -1
- package/docs-site/en/scope.html +1 -1
- package/docs-site/en/security.html +1 -1
- package/docs-site/en/team.html +1 -1
- package/docs-site/en/update.html +3 -3
- package/docs-site/en/whats-new.html +50 -29
- package/docs-site/en/windows.html +1 -1
- package/docs-site/en/workflows.html +1 -1
- package/docs-site/index.html +15 -15
- package/docs-site/mcp-approval.html +1 -1
- package/docs-site/mcp-auth.html +1 -1
- package/docs-site/mcp-servers.html +1 -1
- package/docs-site/mcp.html +1 -1
- package/docs-site/permissions.html +1 -1
- package/docs-site/profiles.html +1 -1
- package/docs-site/quickstart.html +1 -1
- package/docs-site/releases.json +57 -0
- package/docs-site/runtime.html +1 -1
- package/docs-site/scope.html +1 -1
- package/docs-site/security.html +1 -1
- package/docs-site/team.html +1 -1
- package/docs-site/update.html +3 -3
- package/docs-site/whats-new.html +50 -29
- package/docs-site/windows.html +1 -1
- package/docs-site/workflows.html +1 -1
- package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
- package/package.json +1 -1
- package/packages/piagent-core/managed/check-questions.mjs +133 -0
- package/packages/piagent-core/managed/command-tools.mjs +2 -0
- package/packages/piagent-core/managed/helper-run.mjs +1 -1
- package/packages/piagent-core/managed/repository-checks.mjs +80 -0
- package/packages/piagent-core/managed/review-snapshot-request.mjs +3 -1
- package/packages/piagent-core/managed/runtime-snapshot.mjs +63 -0
- package/packages/piagent-core/managed/terminal-boundary.mjs +4 -2
- package/packages/piagent-core/managed/tool-boundary.mjs +7 -7
- package/packages/piagent-core/managed/ts-diagnostics.mjs +1 -1
- package/packages/piagent-core/managed/workflow.mjs +40 -89
- package/packages/piagent-core/package.json +1 -1
- package/packages/piagent-webui/dist/client/assets/{App-DqN7B1JR.js → App-CU-wndsb.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/{GatewayApp-B3--WjSd.js → GatewayApp-MIvgr7kn.js} +6 -6
- package/packages/piagent-webui/dist/client/assets/{InspectionApp-BKQiA0Ce.js → InspectionApp-Bfvr2S8S.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/{SessionAgentWorkspace-ZAdRbxVA.js → SessionAgentWorkspace-0Xowe-sG.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/{SessionInspectorDrawer-B2e4-1FG.js → SessionInspectorDrawer-72Z5908P.js} +2 -2
- package/packages/piagent-webui/dist/client/assets/{SettingsNavigation-C3MC0X4P.js → SettingsNavigation-Bu1A7x4F.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/{SettingsPage-D4os5vHj.js → SettingsPage-DxGxE0AF.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/{TerminalPanel-CVYUiDkn.js → TerminalPanel-BKgx9SjR.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/{WorkspaceCodeEditor-E0Zopcke.js → WorkspaceCodeEditor-DFrh3qs2.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/{WorkspaceExplorer-DldLoJyf.js → WorkspaceExplorer-D6ufO-eP.js} +3 -3
- package/packages/piagent-webui/dist/client/assets/{index-DcrpSyBK.js → index-C3CJv1wM.js} +2 -2
- package/packages/piagent-webui/dist/client/index.html +1 -1
- package/packages/piagent-webui/server/transcript-projection.ts +2 -1
- package/packages/piagent-webui/shared/process-copy.ts +3 -2
- package/schemas/piagent-webui/transcript-v1.schema.json +1 -0
- package/scripts/runtime-advisory-exceptions.json +8 -0
package/docs-site/whats-new.html
CHANGED
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
<span class="brand-mark"><img src="/assets/piagent-logo.svg" alt="" width="28" height="28" /></span>
|
|
38
38
|
<span class="brand-name"><b>Piagent</b> <span>Platform</span></span>
|
|
39
39
|
</a>
|
|
40
|
-
<a class="version-badge" href="/whats-new" data-docs-version="v1.
|
|
40
|
+
<a class="version-badge" href="/whats-new" data-docs-version="v1.49.0" title="v1.49.0 docs · VI"><span class="dot"></span>v1.49.0</a>
|
|
41
41
|
<nav class="top-nav" aria-label="Tài liệu">
|
|
42
42
|
<a href="/quickstart">Tài liệu</a>
|
|
43
43
|
<a href="/whats-new" aria-current="page">Changelog</a>
|
|
@@ -108,7 +108,30 @@
|
|
|
108
108
|
<div class="release-filter" role="toolbar" aria-label="Lọc theo loại thay đổi"><button type="button" data-filter-kind="all" aria-pressed="true">Tất cả</button><button type="button" data-filter-kind="new" aria-pressed="false">Mới</button><button type="button" data-filter-kind="improved" aria-pressed="false">Cải thiện</button><button type="button" data-filter-kind="fixed" aria-pressed="false">Sửa lỗi</button><button type="button" data-filter-kind="security" aria-pressed="false">Bảo mật</button></div>
|
|
109
109
|
<section class="section releases" data-releases>
|
|
110
110
|
<h2 id="latest" class="sr-only">Mới nhất</h2>
|
|
111
|
-
<article class="release latest" id="v1-
|
|
111
|
+
<article class="release latest" id="v1-49-0" data-release>
|
|
112
|
+
<div class="release-meta"><a class="release-version" href="#v1-49-0">v1.49.0</a><time class="release-date" datetime="2026-10-31">31/10/2026</time></div>
|
|
113
|
+
<div class="release-body">
|
|
114
|
+
<h3 data-toc-skip id="v1-49-0-title">Bạn chọn chạy bao nhiêu test</h3>
|
|
115
|
+
<p>Harness công ty không còn ép chạy cả bộ test sau một thay đổi nhỏ: bạn chọn chạy phần liên quan, toàn bộ hay không chạy.</p>
|
|
116
|
+
<ul class="release-items">
|
|
117
|
+
<li data-kind="improved"><span class="kind kind-improved">Cải thiện</span><span>Harness hỏi chạy bao nhiêu test ở cuối thay đổi, trước khi chạy cả bộ test và trước khi push, trên thẻ ở dashboard hoặc trong Terminal.</span></li>
|
|
118
|
+
<li data-kind="improved"><span class="kind kind-improved">Cải thiện</span><span>Bypass tự chạy test liên quan, không hỏi, nhưng vẫn hỏi trước push. Khi không có màn hình để hỏi thì không hỏi gì.</span></li>
|
|
119
|
+
<li data-kind="fixed"><span class="kind kind-fixed">Sửa lỗi</span><span>Khi chỉ chạy một phần test, lượt ghi rõ điều đó thay vì báo check đã pass.</span></li>
|
|
120
|
+
</ul>
|
|
121
|
+
</div>
|
|
122
|
+
</article>
|
|
123
|
+
<article class="release" id="v1-48-5" data-release>
|
|
124
|
+
<div class="release-meta"><a class="release-version" href="#v1-48-5">v1.48.5</a><time class="release-date" datetime="2026-10-31">31/10/2026</time></div>
|
|
125
|
+
<div class="release-body">
|
|
126
|
+
<h3 data-toc-skip id="v1-48-5-title">Task đang chạy vẫn dùng được tool khi Piagent được cập nhật</h3>
|
|
127
|
+
<p>Cập nhật Piagent khi task công ty đang chạy không còn làm hỏng tool: task chạy xong trên bản đã bắt đầu, rồi Terminal mới nạp bản mới.</p>
|
|
128
|
+
<ul class="release-items">
|
|
129
|
+
<li data-kind="fixed"><span class="kind kind-fixed">Sửa lỗi</span><span>Task công ty chạy trong lúc piagent-update không còn bị mọi tool báo managed-runtime-changed: sandbox chạy bản sao riêng, vẫn được kiểm tra, của đúng bản lúc bắt đầu.</span></li>
|
|
130
|
+
<li data-kind="fixed"><span class="kind kind-fixed">Sửa lỗi</span><span>Lượt không đọc được code không còn bị báo là đã đổi code, trừ khi thật sự có sửa file.</span></li>
|
|
131
|
+
</ul>
|
|
132
|
+
</div>
|
|
133
|
+
</article>
|
|
134
|
+
<article class="release" id="v1-48-4" data-release>
|
|
112
135
|
<div class="release-meta"><a class="release-version" href="#v1-48-4">v1.48.4</a><time class="release-date" datetime="2026-10-30">30/10/2026</time></div>
|
|
113
136
|
<div class="release-body">
|
|
114
137
|
<h3 data-toc-skip id="v1-48-4-title">/reload không còn làm hỏng Terminal công ty; nạp bản mới không làm mất tin nhắn</h3>
|
|
@@ -396,38 +419,36 @@
|
|
|
396
419
|
</ul>
|
|
397
420
|
</div>
|
|
398
421
|
</article>
|
|
399
|
-
<article class="release" id="v1-43-4" data-release>
|
|
400
|
-
<div class="release-meta"><a class="release-version" href="#v1-43-4">v1.43.4</a><time class="release-date" datetime="2026-10-26">26/10/2026</time></div>
|
|
401
|
-
<div class="release-body">
|
|
402
|
-
<h3 data-toc-skip id="v1-43-4-title">Dev chọn workflow, harness hỗ trợ BE/FE theo project</h3>
|
|
403
|
-
<p>Cuộc trò chuyện công ty nhận diện project và kế thừa skill, plan, nguồn requirement của Dev, không ép thêm phương pháp mới. Giữ nguyên quyền hạn và cơ chế duyệt.</p>
|
|
404
|
-
<ul class="release-items">
|
|
405
|
-
<li data-kind="new"><span class="kind kind-new">Mới</span><span>Tự nhận diện repo/package FE, BE trong thư mục hội thoại và gợi ý checks phù hợp, làm mới theo task.</span></li>
|
|
406
|
-
<li data-kind="new"><span class="kind kind-new">Mới</span><span>Context workflow tùy chọn nhớ nguồn requirement, plan và tasks của công việc khi mở lại hội thoại, truyền chỉ đọc cho helper và hỗ trợ đổi hoặc xóa.</span></li>
|
|
407
|
-
<li data-kind="improved"><span class="kind kind-improved">Cải thiện</span><span>Lifecycle là hướng dẫn theo workflow và điểm dừng Dev chọn, không ép file spec hoặc tự tạo gate release. Skill được chọn không cấp quyền hay bỏ qua kiểm chứng, review và duyệt deploy.</span></li>
|
|
408
|
-
<li data-kind="fixed"><span class="kind kind-fixed">Sửa lỗi</span><span>Tải lịch sử kiểm tra lại vị trí cuộn và phản hồi khi cuộn lên, tránh sự kiện observer cũ tải thừa trang hoặc làm lịch sử ngừng tải.</span></li>
|
|
409
|
-
<li data-kind="fixed"><span class="kind kind-fixed">Sửa lỗi</span><span>Review chạy được ở thư mục chứa nhiều repo: mỗi file thay đổi được review trong repo của nó, file ngoài repo được ghi rõ là không review.</span></li>
|
|
410
|
-
</ul>
|
|
411
|
-
</div>
|
|
412
|
-
</article>
|
|
413
|
-
<article class="release" id="v1-43-3" data-release>
|
|
414
|
-
<div class="release-meta"><a class="release-version" href="#v1-43-3">v1.43.3</a><time class="release-date" datetime="2026-10-26">26/10/2026</time></div>
|
|
415
|
-
<div class="release-body">
|
|
416
|
-
<h3 data-toc-skip id="v1-43-3-title">Dev chọn workflow, harness hỗ trợ BE/FE theo project <span class="tag">không phát hành</span></h3>
|
|
417
|
-
<p>Đã tag nhưng chưa publish: E2E lịch sử WebUI trên Linux phát hiện race giữa scroll và observer. Thay bằng 1.43.4.</p>
|
|
418
|
-
<ul class="release-items">
|
|
419
|
-
<li data-kind="new"><span class="kind kind-new">Mới</span><span>Tự nhận diện repo/package FE, BE trong thư mục hội thoại và gợi ý checks phù hợp, làm mới theo task.</span></li>
|
|
420
|
-
<li data-kind="new"><span class="kind kind-new">Mới</span><span>Context workflow tùy chọn nhớ nguồn requirement, plan và tasks của công việc khi mở lại hội thoại, truyền chỉ đọc cho helper và hỗ trợ đổi hoặc xóa.</span></li>
|
|
421
|
-
<li data-kind="improved"><span class="kind kind-improved">Cải thiện</span><span>Lifecycle là hướng dẫn theo workflow và điểm dừng Dev chọn, không ép file spec hoặc tự tạo gate release. Skill được chọn không cấp quyền hay bỏ qua kiểm chứng, review và duyệt deploy.</span></li>
|
|
422
|
-
</ul>
|
|
423
|
-
</div>
|
|
424
|
-
</article>
|
|
425
422
|
</section>
|
|
426
423
|
<section class="section archive">
|
|
427
424
|
<h2 id="archive">Các bản trước</h2>
|
|
428
425
|
<p class="section-lead">Bản cũ hơn, gom theo tháng. Mở một tháng để xem từng thay đổi.</p>
|
|
429
426
|
<details class="release-archive">
|
|
430
|
-
<summary><span>Tháng 10/2026</span><span class="archive-count">
|
|
427
|
+
<summary><span>Tháng 10/2026</span><span class="archive-count">69</span></summary>
|
|
428
|
+
<div class="archive-row" id="v1-43-4">
|
|
429
|
+
<div class="archive-head"><a class="release-version" href="#v1-43-4">v1.43.4</a> <span class="release-date">26/10/2026</span></div>
|
|
430
|
+
<div class="archive-body">
|
|
431
|
+
<p><strong>Dev chọn workflow, harness hỗ trợ BE/FE theo project</strong> — Cuộc trò chuyện công ty nhận diện project và kế thừa skill, plan, nguồn requirement của Dev, không ép thêm phương pháp mới. Giữ nguyên quyền hạn và cơ chế duyệt.</p>
|
|
432
|
+
<ul class="release-items">
|
|
433
|
+
<li data-kind="new"><span class="kind kind-new">Mới</span><span>Tự nhận diện repo/package FE, BE trong thư mục hội thoại và gợi ý checks phù hợp, làm mới theo task.</span></li>
|
|
434
|
+
<li data-kind="new"><span class="kind kind-new">Mới</span><span>Context workflow tùy chọn nhớ nguồn requirement, plan và tasks của công việc khi mở lại hội thoại, truyền chỉ đọc cho helper và hỗ trợ đổi hoặc xóa.</span></li>
|
|
435
|
+
<li data-kind="improved"><span class="kind kind-improved">Cải thiện</span><span>Lifecycle là hướng dẫn theo workflow và điểm dừng Dev chọn, không ép file spec hoặc tự tạo gate release. Skill được chọn không cấp quyền hay bỏ qua kiểm chứng, review và duyệt deploy.</span></li>
|
|
436
|
+
<li data-kind="fixed"><span class="kind kind-fixed">Sửa lỗi</span><span>Tải lịch sử kiểm tra lại vị trí cuộn và phản hồi khi cuộn lên, tránh sự kiện observer cũ tải thừa trang hoặc làm lịch sử ngừng tải.</span></li>
|
|
437
|
+
<li data-kind="fixed"><span class="kind kind-fixed">Sửa lỗi</span><span>Review chạy được ở thư mục chứa nhiều repo: mỗi file thay đổi được review trong repo của nó, file ngoài repo được ghi rõ là không review.</span></li>
|
|
438
|
+
</ul>
|
|
439
|
+
</div>
|
|
440
|
+
</div>
|
|
441
|
+
<div class="archive-row" id="v1-43-3">
|
|
442
|
+
<div class="archive-head"><a class="release-version" href="#v1-43-3">v1.43.3</a> <span class="release-date">26/10/2026</span></div>
|
|
443
|
+
<div class="archive-body">
|
|
444
|
+
<p><strong>Dev chọn workflow, harness hỗ trợ BE/FE theo project</strong> <span class="tag">không phát hành</span> — Đã tag nhưng chưa publish: E2E lịch sử WebUI trên Linux phát hiện race giữa scroll và observer. Thay bằng 1.43.4.</p>
|
|
445
|
+
<ul class="release-items">
|
|
446
|
+
<li data-kind="new"><span class="kind kind-new">Mới</span><span>Tự nhận diện repo/package FE, BE trong thư mục hội thoại và gợi ý checks phù hợp, làm mới theo task.</span></li>
|
|
447
|
+
<li data-kind="new"><span class="kind kind-new">Mới</span><span>Context workflow tùy chọn nhớ nguồn requirement, plan và tasks của công việc khi mở lại hội thoại, truyền chỉ đọc cho helper và hỗ trợ đổi hoặc xóa.</span></li>
|
|
448
|
+
<li data-kind="improved"><span class="kind kind-improved">Cải thiện</span><span>Lifecycle là hướng dẫn theo workflow và điểm dừng Dev chọn, không ép file spec hoặc tự tạo gate release. Skill được chọn không cấp quyền hay bỏ qua kiểm chứng, review và duyệt deploy.</span></li>
|
|
449
|
+
</ul>
|
|
450
|
+
</div>
|
|
451
|
+
</div>
|
|
431
452
|
<div class="archive-row" id="v1-43-2">
|
|
432
453
|
<div class="archive-head"><a class="release-version" href="#v1-43-2">v1.43.2</a> <span class="release-date">26/10/2026</span></div>
|
|
433
454
|
<div class="archive-body">
|
package/docs-site/windows.html
CHANGED
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
<span class="brand-mark"><img src="/assets/piagent-logo.svg" alt="" width="28" height="28" /></span>
|
|
38
38
|
<span class="brand-name"><b>Piagent</b> <span>Platform</span></span>
|
|
39
39
|
</a>
|
|
40
|
-
<a class="version-badge" href="/whats-new" data-docs-version="v1.
|
|
40
|
+
<a class="version-badge" href="/whats-new" data-docs-version="v1.49.0" title="v1.49.0 docs · VI"><span class="dot"></span>v1.49.0</a>
|
|
41
41
|
<nav class="top-nav" aria-label="Tài liệu">
|
|
42
42
|
<a href="/quickstart">Tài liệu</a>
|
|
43
43
|
<a href="/whats-new">Changelog</a>
|
package/docs-site/workflows.html
CHANGED
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
<span class="brand-mark"><img src="/assets/piagent-logo.svg" alt="" width="28" height="28" /></span>
|
|
38
38
|
<span class="brand-name"><b>Piagent</b> <span>Platform</span></span>
|
|
39
39
|
</a>
|
|
40
|
-
<a class="version-badge" href="/whats-new" data-docs-version="v1.
|
|
40
|
+
<a class="version-badge" href="/whats-new" data-docs-version="v1.49.0" title="v1.49.0 docs · VI"><span class="dot"></span>v1.49.0</a>
|
|
41
41
|
<nav class="top-nav" aria-label="Tài liệu">
|
|
42
42
|
<a href="/quickstart">Tài liệu</a>
|
|
43
43
|
<a href="/whats-new">Changelog</a>
|
|
@@ -256,8 +256,8 @@
|
|
|
256
256
|
},
|
|
257
257
|
{
|
|
258
258
|
"path": "tests",
|
|
259
|
-
"fileCount":
|
|
260
|
-
"sha256": "
|
|
259
|
+
"fileCount": 647,
|
|
260
|
+
"sha256": "0ec8e3ca46f9a7ef8b44de7f1a31330f63a12e388205e4419530a8bed9cb82be"
|
|
261
261
|
}
|
|
262
262
|
],
|
|
263
263
|
"custodianComparisonRules": {
|
package/package.json
CHANGED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { memberQuestions } from './member-questions.mjs';
|
|
3
|
+
import { effectivePermission } from './permission.mjs';
|
|
4
|
+
|
|
5
|
+
// How much to test is the member's decision: a one-line fix should not cost a
|
|
6
|
+
// thirty-minute suite. The harness asks at the end of a change, before a whole
|
|
7
|
+
// suite and before a push, on the dashboard card or in the Terminal. Bypass skips
|
|
8
|
+
// the questions about the work (it runs the tests that cover the change) but not
|
|
9
|
+
// the one before a push. With no screen nobody can answer: nothing is asked.
|
|
10
|
+
|
|
11
|
+
// How much of the suite a test command covers: 'partial' (a filter, one file, one
|
|
12
|
+
// package), 'suite' (the whole test command) or null when it runs no tests.
|
|
13
|
+
const VERSION_ONLY = /(?:^|\s)(?:--version|-V|--help)(?:\s|$)/;
|
|
14
|
+
const FILTERED = /(?:^|\s)(?:-k|-t|--grep|--testNamePattern|--test-name-pattern|--filter|--only|--testPathPattern)(?:[\s=]|$)|\bgo\s+test\b.*\s-run[\s=]|\bpytest\b.*\s::|\.only\b/;
|
|
15
|
+
const TEST_RUNNER = /\b(npm|pnpm|yarn|bun)\s+(run\s+)?test(:\S+)?(?=\s|$)|\bnode\s+--test\b|\b(vitest|jest|mocha|pytest|rspec|phpunit)\b|\bplaywright\s+test\b|\bpython3?\s+-m\s+(pytest|unittest)\b|\b(go|cargo|swift|dotnet|deno)\s+test\b|\bmake\s+test\b|\bxcodebuild\b.*\btest\b|\b(gradle|gradlew|mvn|mvnw)\b.*\b(test|verify)\b/;
|
|
16
|
+
// Test commands start a command: `npm install -D vitest` or `rm jest.config.ts` only name one.
|
|
17
|
+
const TEST_START = new RegExp(`^(?:${TEST_RUNNER.source})`);
|
|
18
|
+
const LAUNCHER = /^(?:\w+=\S*\s+|time\s+|nohup\s+|timeout\s+\S+\s+|npx\s+(?:-\S+\s+)*|bunx\s+|pnpm\s+(?:exec|dlx)\s+|uv\s+run\s+|poetry\s+run\s+|yarn\s+(?=jest|vitest|mocha|playwright)|node_modules\/\.bin\/|\.\/)+/;
|
|
19
|
+
const partsOf = bare => bare.split(/\s*(?:\|\|?|&&|;)\s*/).map(part => part.replace(/\s\d*>.*$/, '').trim());
|
|
20
|
+
const NOT_TESTS = /\b(lint|typecheck|type-check|tsc|eslint|biome|ruff|mypy|pyright|vet|clippy|format|build)\b/;
|
|
21
|
+
const TARGET_FLAG = /^(-p|--package|-w|--workspace|--tests)$|^(-Dtest=|-only-testing:|--workspace=|--package=)/;
|
|
22
|
+
const VALUE_FLAG = /^(-c|--config|--reporter|--project|--workers|--timeout|--rootDir|--root|-f|--file|-o|--output)$/;
|
|
23
|
+
// A file or folder named on the command line; a flag's own value names none.
|
|
24
|
+
const targetsPart = args => args.some((a, i) => {
|
|
25
|
+
if (TARGET_FLAG.test(a)) return true;
|
|
26
|
+
if (a.startsWith('-') || VALUE_FLAG.test(args[i - 1] ?? '') || a.includes('*') || a.endsWith('...')) return false;
|
|
27
|
+
return a.includes('/') || /\.(test|spec)\.\w+$|_test\.\w+$|^test_\w+\.\w+$/.test(a);
|
|
28
|
+
});
|
|
29
|
+
// What a command runs, without the text it merely quotes (a commit message, an echo).
|
|
30
|
+
const unquoted = text => text.replace(/"[^"]*"|'[^']*'/g, '""');
|
|
31
|
+
export function checkScope(command, declared = []) {
|
|
32
|
+
const text = String(command ?? '').replace(/\s+/g, ' ').trim();
|
|
33
|
+
if (!text || /^(echo|printf|grep|rg|cat|sed|awk|head|tail|less|git|ls|find)\b/.test(text) || VERSION_ONLY.test(text) || /--collect-only/.test(text)) return null;
|
|
34
|
+
const bare = unquoted(text);
|
|
35
|
+
for (const part of partsOf(bare)) {
|
|
36
|
+
const own = part.replace(LAUNCHER, ''), runner = own.match(TEST_START);
|
|
37
|
+
if (!runner) continue;
|
|
38
|
+
if (FILTERED.test(own)) return 'partial';
|
|
39
|
+
return !declared.includes(text) && targetsPart(own.slice(runner[0].length).split(' ').filter(Boolean)) ? 'partial' : 'suite';
|
|
40
|
+
}
|
|
41
|
+
// A script the repository declares: it cannot be narrowed from here.
|
|
42
|
+
const named = declared.find(d => partsOf(bare).some(part => part.startsWith(unquoted(d))));
|
|
43
|
+
return named && !NOT_TESTS.test(named) ? 'suite' : null;
|
|
44
|
+
}
|
|
45
|
+
// The member's own words asking for the whole suite: nothing to ask them then.
|
|
46
|
+
const RUN_ALL = String.raw`(?:full|all(?:\s+the)?|whole(?:\s+the)?|entire(?:\s+the)?|the\s+(?:full|whole|entire))`;
|
|
47
|
+
const FULL_WORDS = new RegExp([
|
|
48
|
+
String.raw`(?:^|\s)(?:run|rerun|execute)\s+${RUN_ALL}\s+(?:tests?|suite|checks?)(?:\s|$|[.,!?])`,
|
|
49
|
+
String.raw`(?:^|\s)(?:chạy|chay)(?:\s+lại)?\s+(?:full|toàn bộ|toan bo|tất cả|tat ca|hết|het)(?:\s+các)?\s+(?:tests?|suite|kiểm thử|kiểm tra)`,
|
|
50
|
+
String.raw`(?:^|\s)(?:chạy|chay)(?:\s+lại)?\s+tests?\s+(?:full|toàn bộ|toan bo|tất cả|tat ca|hết|het)`,
|
|
51
|
+
].join('|'), 'i');
|
|
52
|
+
const NOT_WORDS = /(không|khong|đừng|chưa|\bno\b|\bnot\b|don't|skip|bỏ qua)[^.!?\n]{0,24}$/i;
|
|
53
|
+
export function asksFullChecks(text) {
|
|
54
|
+
const value = String(text ?? ''), hit = value.match(FULL_WORDS);
|
|
55
|
+
return !!hit && !NOT_WORDS.test(value.slice(0, hit.index));
|
|
56
|
+
}
|
|
57
|
+
// A push the harness can see; handoff-policy still asks for its own approval.
|
|
58
|
+
export const isGitPush = command => /(^|[\s;&|(])git\s+(?:(?:-C|-c)\s+\S+\s+|--\S+\s+)*push\b/.test(unquoted(String(command ?? '')));
|
|
59
|
+
|
|
60
|
+
const PART = 'Chỉ chạy phần liên quan', KEEP = 'Giữ kết quả phần đã chạy', FULL = 'Chạy toàn bộ', NONE = 'Không chạy lúc này';
|
|
61
|
+
const PUSH_FULL = 'Chạy toàn bộ rồi mới push', PUSH_NOW = 'Push luôn, không chạy thêm';
|
|
62
|
+
const GATEWAY_UI = Symbol.for('piagent.webui.gateway-runtime-ui.v1');
|
|
63
|
+
|
|
64
|
+
// The screen to ask on: the dashboard's card, else the Terminal. The turn's end
|
|
65
|
+
// has no tool context, so the Terminal comes from the extension runner there.
|
|
66
|
+
function surfaceOf(self, ctx) {
|
|
67
|
+
const sessionId = self.session?.sessionManager?.getSessionId?.();
|
|
68
|
+
if (sessionId && memberQuestions.hasSurface(sessionId)) return { sessionId };
|
|
69
|
+
const runner = self.session?.extensionRunner, ui = ctx ? ctx.hasUI && ctx.ui : runner?.hasUI?.() && runner.getUIContext?.();
|
|
70
|
+
return ui?.select && !ui[GATEWAY_UI] ? { ui } : null;
|
|
71
|
+
}
|
|
72
|
+
// The label picked, { other } for the member's own words, undefined for no answer (Esc, stopped).
|
|
73
|
+
async function ask(surface, question, labels, signal) {
|
|
74
|
+
if (surface.ui) return surface.ui.select(question, labels, { signal });
|
|
75
|
+
const questions = [{ header: 'Test', question, options: labels.map(label => ({ label, description: '' })), multiSelect: false }];
|
|
76
|
+
const answer = await memberQuestions.ask({ sessionId: surface.sessionId, toolCallId: `check-scope-${randomUUID()}`, questions, signal }).catch(() => null);
|
|
77
|
+
if (!answer || answer.skipped) return undefined;
|
|
78
|
+
const { selected, other } = answer.answers[0];
|
|
79
|
+
return other ? { other } : labels[selected[0]];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// At the end of a change: { choice: 'partial' | 'full' | 'skip', words? }, or null
|
|
83
|
+
// when nobody can answer. No answer takes the cheap choice: the status says only part ran.
|
|
84
|
+
export async function askCheckScope(self, { partPassed = false, signal } = {}) {
|
|
85
|
+
const surface = surfaceOf(self);
|
|
86
|
+
if (!surface) return null;
|
|
87
|
+
if (effectivePermission(self) === 'bypass') return { choice: 'partial' };
|
|
88
|
+
const part = partPassed ? KEEP : PART;
|
|
89
|
+
const picked = await ask(surface, partPassed ? 'Code đã đổi, phần test liên quan đã pass. Có chạy toàn bộ test không?' : 'Code đã đổi, chưa chạy test. Chạy phần nào?', [part, FULL, NONE], signal);
|
|
90
|
+
if (signal?.aborted) return null;
|
|
91
|
+
if (picked?.other) return { choice: 'partial', words: picked.other };
|
|
92
|
+
return { choice: picked === FULL ? 'full' : picked === NONE ? 'skip' : 'partial' };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const PARTIAL_REASON = 'managed-check-scope: the member chose to run only the tests that cover the change. Run one file, a filter or one package, not the whole suite. If this project has no way to run part of it, say so and repeat the command: the member is asked.';
|
|
96
|
+
const BYPASS_REASON = 'managed-check-scope: run the tests that cover the change (one file, a filter, one package). A whole-suite run needs the member to ask for it, or their answer before a push. If this project has no way to run part of it, say so and repeat the command.';
|
|
97
|
+
const PUSH_REASON = 'managed-check-scope: the member wants the whole suite before this push. Run it, fix what fails, then push.';
|
|
98
|
+
const STOPPED_REASON = 'managed-check-scope: the member did not answer whether to push now. Do not push; say so and let them decide.';
|
|
99
|
+
const WORDS_REASON = words => `managed-check-scope: this command did not run. The member answered about testing: "${String(words).slice(0, 500)}". Follow that.`;
|
|
100
|
+
|
|
101
|
+
// Before a whole suite or a push: returns to let it run, throws to send the agent another way.
|
|
102
|
+
export async function beforeCheckOrPush(self, command, ctx, signal) {
|
|
103
|
+
const run = self.run;
|
|
104
|
+
if (!run || run.policy?.verify === 'off') return;
|
|
105
|
+
// Calls sent together wait for one answer: the next one sees it.
|
|
106
|
+
const turn = (run.scopeQuestion ?? Promise.resolve()).then(() => holdOrLet(self, run, command, ctx, signal));
|
|
107
|
+
run.scopeQuestion = turn.catch(() => {});
|
|
108
|
+
return turn;
|
|
109
|
+
}
|
|
110
|
+
async function holdOrLet(self, run, command, ctx, signal) {
|
|
111
|
+
const push = isGitPush(command);
|
|
112
|
+
if (!push && (checkScope(command, run.checks.commands) !== 'suite' || run.fullAsked || run.scopeChoice === 'full')) return;
|
|
113
|
+
const surface = surfaceOf(self, ctx);
|
|
114
|
+
if (!surface) return;
|
|
115
|
+
if (push) {
|
|
116
|
+
const now = await self.digest();
|
|
117
|
+
if (run.suitePassedOn(now) || (run.pushAllowedOn !== undefined && run.pushAllowedOn === now)) return;
|
|
118
|
+
const picked = await ask(surface, 'Sắp push code nhưng toàn bộ test chưa pass trên code này. Làm gì?', [PUSH_FULL, PUSH_NOW], signal);
|
|
119
|
+
if (picked === PUSH_NOW) { run.pushAllowedOn = now; return; }
|
|
120
|
+
if (picked?.other) throw Error(WORDS_REASON(picked.other));
|
|
121
|
+
if (picked !== PUSH_FULL) throw Error(STOPPED_REASON);
|
|
122
|
+
run.scopeChoice = 'full';
|
|
123
|
+
throw Error(PUSH_REASON);
|
|
124
|
+
}
|
|
125
|
+
// Sent another way once; the same command again means the part did not do: the member decides (Bypass: it runs).
|
|
126
|
+
const held = run.scopeHeld ??= new Set(), again = held.has(command); held.add(command);
|
|
127
|
+
if (effectivePermission(self) === 'bypass') { if (again) return; throw Error(BYPASS_REASON); }
|
|
128
|
+
if (run.scopeChoice === 'partial' && !again) throw Error(PARTIAL_REASON);
|
|
129
|
+
const picked = await ask(surface, `Agent muốn chạy toàn bộ test: ${String(command).slice(0, 160)}`, [PART, FULL], signal);
|
|
130
|
+
if (picked === FULL) { run.scopeChoice = 'full'; return; }
|
|
131
|
+
run.scopeChoice = 'partial';
|
|
132
|
+
throw Error(picked?.other ? WORDS_REASON(picked.other) : PARTIAL_REASON);
|
|
133
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import { beforeCheckOrPush } from './check-questions.mjs';
|
|
2
3
|
import { asksOnlyHandoff, broadAccess, effectivePermission, networkConfirmation } from './permission.mjs';
|
|
3
4
|
import { handoffReason } from '../runtime/policy/handoff-policy.mjs';
|
|
4
5
|
import { engineAlive, engineLauncher } from './container-engine.mjs';
|
|
@@ -105,6 +106,7 @@ function commandTool(self, kind) {
|
|
|
105
106
|
if (memberOnly) throw Error(MEMBER_ONLY(memberOnly));
|
|
106
107
|
const notSimulator = kind === 'simulator' ? simulatorCommandReason(args.command, self.cwd) : null;
|
|
107
108
|
if (notSimulator) throw Error(`managed-simulator-command: ${notSimulator}.`);
|
|
109
|
+
if (kind !== 'simulator') await beforeCheckOrPush(self, args.command, ctx, signal); // how much to test, before the command's own approval
|
|
108
110
|
const confirm = await spec.confirm(self, args.command);
|
|
109
111
|
if (confirm !== null) {
|
|
110
112
|
const why = confirm === 'ask' ? null : stillAsks(self, confirm), handoff = await handoffReason(args.command);
|
|
@@ -144,7 +144,7 @@ async function answerUnder(managed, key, task, signal, harness, snapshot, parts,
|
|
|
144
144
|
(managed.jobTitles ??= new WeakMap()).set(runtime, jobTitle(task, title));
|
|
145
145
|
const setup = HELPER_SETUP[role], memory = helperMemoryTools(managed, () => grant, renew);
|
|
146
146
|
boundary = new ManagedToolBoundary({ cwd: managed.cwd, sdkRoot: managed.sdk, protectedRoots: managed.protectedRoots, readOnly: true, commands: setup.commands });
|
|
147
|
-
const checks = role === 'verify' ? `\n\nRepository checks: ${managed.checks.commands.length ? managed.checks.commands.map(c => '`' + c + '`').join(', ') : 'none declared or detected; choose the tests, type check or build that cover the claims'}
|
|
147
|
+
const checks = role === 'verify' ? `\n\nRepository checks: ${managed.checks.commands.length ? managed.checks.commands.map(c => '`' + c + '`').join(', ') : 'none declared or detected; choose the tests, type check or build that cover the claims'}.${managed.run?.fullAsked || managed.run?.scopeChoice === 'full' ? '' : ' Run only the tests that cover the claims: the member decides about a whole-suite run.'}` : '';
|
|
148
148
|
// One helper session answers the brief (with one part of the patch for a
|
|
149
149
|
// review in parts); its reply and tokens. A helper runs at its role's
|
|
150
150
|
// level from Studio (fixed by the Harness, or its main agent's).
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { discoverDeliveryContext } from './delivery-context.mjs';
|
|
4
|
+
|
|
5
|
+
// Checks come from the repository: a "## Checks" section in AGENTS.md (code
|
|
6
|
+
// block lines or `command` bullets), else what its manifests imply.
|
|
7
|
+
const HEADING = /^#{1,6}\s+(checks?|verification|kiểm tra)\s*$/i;
|
|
8
|
+
export function declaredChecks(agentsFiles = []) {
|
|
9
|
+
const out = [];
|
|
10
|
+
for (const file of agentsFiles) {
|
|
11
|
+
let inside = false, fence = false;
|
|
12
|
+
for (const line of String(file.content ?? '').split('\n')) {
|
|
13
|
+
if (!fence && /^#{1,6}\s/.test(line)) { inside = HEADING.test(line.trim()); continue; }
|
|
14
|
+
if (!inside) continue;
|
|
15
|
+
if (/^\s*(```|~~~)/.test(line)) { fence = !fence; continue; }
|
|
16
|
+
const command = (fence ? line : line.match(/^\s*[-*]\s+`([^`]+)`/)?.[1] ?? '').trim();
|
|
17
|
+
if (command && !command.startsWith('#') && command.length <= 200 && !out.includes(command) && out.length < 10) out.push(command);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
export function detectedChecks(dir) {
|
|
23
|
+
const has = name => { try { const s = fs.lstatSync(path.join(dir, name)); return s.isFile() && s.size <= 1024 * 1024; } catch { return false; } };
|
|
24
|
+
const out = [];
|
|
25
|
+
if (has('package.json')) {
|
|
26
|
+
try {
|
|
27
|
+
const scripts = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).scripts ?? {};
|
|
28
|
+
const runner = has('pnpm-lock.yaml') ? 'pnpm' : has('yarn.lock') ? 'yarn' : has('bun.lock') || has('bun.lockb') ? 'bun' : 'npm';
|
|
29
|
+
for (const name of ['typecheck', 'type-check', 'lint', 'test', 'test:unit', 'test:integration', 'test:api', 'test:contract', 'test:e2e']) if (typeof scripts[name] === 'string') out.push(name === 'test' && runner === 'npm' ? 'npm test' : `${runner} run ${name}`);
|
|
30
|
+
} catch { /* not JSON: nothing implied */ }
|
|
31
|
+
}
|
|
32
|
+
if (has('go.mod')) out.push('go vet ./...', 'go test ./...');
|
|
33
|
+
if (has('Package.swift')) out.push('swift build', 'swift test');
|
|
34
|
+
if (has('Cargo.toml')) out.push('cargo check', 'cargo test');
|
|
35
|
+
if (has('pyproject.toml') || has('pytest.ini')) out.push(has('uv.lock') ? 'uv run pytest' : 'pytest');
|
|
36
|
+
if (has('pom.xml')) out.push(has('mvnw') ? './mvnw verify' : 'mvn verify');
|
|
37
|
+
if (has('build.gradle') || has('build.gradle.kts')) out.push(has('gradlew') ? './gradlew test' : 'gradle test');
|
|
38
|
+
return out.slice(0, 16);
|
|
39
|
+
}
|
|
40
|
+
export function repositoryChecks(agentsFiles, cwd, top = cwd) {
|
|
41
|
+
const declared = declaredChecks(agentsFiles);
|
|
42
|
+
if (declared.length) return { source: 'agents', commands: declared };
|
|
43
|
+
const detected = detectedChecks(cwd);
|
|
44
|
+
// Include package/repository-local checks even when the workspace root has a
|
|
45
|
+
// lint command. These are candidates, not a request to test unrelated repos.
|
|
46
|
+
const quote = value => "'" + value.replaceAll("'", "'\\''") + "'";
|
|
47
|
+
const nested = discoverDeliveryContext(cwd).units.filter(unit => unit.path !== '.').flatMap(unit =>
|
|
48
|
+
detectedChecks(path.join(cwd, unit.path)).map(command => `cd ${quote(unit.path)} && ${command}`));
|
|
49
|
+
const commands = [...new Set([...detected, ...nested])].slice(0, 64);
|
|
50
|
+
return { source: 'detected', commands: commands.length || cwd === top ? commands : detectedChecks(top) };
|
|
51
|
+
}
|
|
52
|
+
// A command that verifies code: a declared check, or a known test, type,
|
|
53
|
+
// lint or build runner. Text tools that merely mention one do not count.
|
|
54
|
+
const RUNNER = /\b(npm|pnpm|yarn|bun)\s+(run\s+)?(test|lint|typecheck|type-check|check|build|verify)\b|\bnode\s+--test\b|\b(tsc|vitest|jest|eslint|biome|ruff|mypy|pyright|pytest|rspec|phpunit)\b|\bplaywright\s+test\b|\bpython3?\s+-m\s+(pytest|unittest|mypy)\b|\bgo\s+(test|vet|build)\b|\bcargo\s+(test|check|clippy|build)\b|\bswift\s+(test|build)\b|\bxcodebuild\b.*\b(test|build)\b|\bmake\s+(test|check|lint)\b|\b(gradle|gradlew|mvn|mvnw)\b.*\b(test|check|verify|compile|package|build)\b|\bdotnet\s+(test|build)\b|\bdeno\s+(test|check|lint)\b/;
|
|
55
|
+
// Files no check covers: documentation, pictures and documents,
|
|
56
|
+
// and Spec Kit's .specify/ state. A turn that changed only these changed no
|
|
57
|
+
// code: no check is required of it (one is still reported if it ran).
|
|
58
|
+
const NON_CODE = /\.(md|mdx|markdown|rst|adoc|asciidoc|org|png|jpe?g|gif|webp|bmp|ico|pdf|docx?|xlsx?|pptx?|odt|ods|odp|rtf)$/i;
|
|
59
|
+
export function nonCodePath(file) {
|
|
60
|
+
const name = String(file ?? '').replaceAll('\\', '/');
|
|
61
|
+
return !!name && (NON_CODE.test(name) || /(^|\/)\.specify\//.test(name) || /(^|\/)(LICENSE|NOTICE|AUTHORS|CODEOWNERS)$/.test(name));
|
|
62
|
+
}
|
|
63
|
+
export const onlyNonCode = paths => { const list = [...(paths ?? [])]; return list.length > 0 && list.every(nonCodePath); };
|
|
64
|
+
|
|
65
|
+
// A command that only reads: each part starts with a reading tool and nothing
|
|
66
|
+
// is written to a file. Anything else may change files. Git may name the
|
|
67
|
+
// repository first (git -C <folder>): a workspace folder holding several
|
|
68
|
+
// repositories is read that way.
|
|
69
|
+
const GIT_READ = String.raw`git(\s+(-C\s+('[^']*'|"[^"]*"|\S+)|-c\s+\S+|--no-pager))*\s+(status|diff|log|show|branch|ls-files|rev-parse|rev-list|blame|describe|shortlog|cat-file|for-each-ref|reflog(?!\s+(expire|delete))|remote(\s+(-v|--verbose|show|get-url)\b|$))`;
|
|
70
|
+
const READER = new RegExp(String.raw`^(ls|cat|head|tail|wc|grep|egrep|rg|find|fd|sed|awk|cut|sort|uniq|tr|echo|printf|pwd|which|stat|file|du|df|tree|jq|diff|cmp|basename|dirname|realpath|date|true|cd|${GIT_READ})(\b|$)`);
|
|
71
|
+
export function isReadOnlyCommand(command) {
|
|
72
|
+
const text = String(command ?? '').trim();
|
|
73
|
+
if (!text || />(?!>|\s*&\d|\s*\/dev\/null)|\btee\b|\bsed\s+(-[a-zA-Z]*i|--in-place)|\s-(delete|exec|execdir|fprint|ok)\b|\bxargs\b|\$\(|`/.test(text)) return false;
|
|
74
|
+
return text.split(/&&|\|\||;|\||\n/).every(part => !part.trim() || READER.test(part.trim()));
|
|
75
|
+
}
|
|
76
|
+
export function isCheckCommand(command, declared = []) {
|
|
77
|
+
const text = String(command ?? '').replace(/\s+/g, ' ').trim();
|
|
78
|
+
if (!text || /^(echo|printf|grep|rg|cat|sed|awk|head|tail|less|git|ls|find)\b/.test(text)) return false;
|
|
79
|
+
return declared.some(d => text.includes(d)) || RUNNER.test(text);
|
|
80
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { fileURLToPath } from 'node:url';
|
|
2
2
|
|
|
3
3
|
const worker = fileURLToPath(new URL('./review-snapshot-worker.mjs', import.meta.url));
|
|
4
|
+
// The session's own copy of the worker (runtime-snapshot.mjs).
|
|
5
|
+
const workerOf = boundary => boundary.runtimeFile?.('review-snapshot-worker.mjs') ?? worker;
|
|
4
6
|
const requests = new WeakSet();
|
|
5
7
|
const quote = value => "'" + value.replaceAll("'", "'\"'\"'") + "'";
|
|
6
8
|
|
|
@@ -9,7 +11,7 @@ const quote = value => "'" + value.replaceAll("'", "'\"'\"'") + "'";
|
|
|
9
11
|
export function invokeReviewSnapshot(boundary, mode, extra = []) {
|
|
10
12
|
if (!['head', 'heads', 'since', 'branch-base', 'digest', 'file', 'files'].includes(mode)
|
|
11
13
|
|| !Array.isArray(extra) || extra.some(value => typeof value !== 'string')) throw Error('review-request-invalid');
|
|
12
|
-
const args = Object.freeze({ command: [process.execPath,
|
|
14
|
+
const args = Object.freeze({ command: [process.execPath, workerOf(boundary), mode, ...extra].map(quote).join(' '), timeout: 60 });
|
|
13
15
|
requests.add(args);
|
|
14
16
|
return boundary.invoke('bash', args);
|
|
15
17
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// The code a company session's sandbox runs, kept apart from the install.
|
|
2
|
+
//
|
|
3
|
+
// A session pins the hash of its workers and refuses every tool when one
|
|
4
|
+
// changes. piagent-update rewrites the install in place, so a task running
|
|
5
|
+
// across an update lost all its tools (managed-runtime-changed) while a helper
|
|
6
|
+
// started afterwards worked. A session now runs its workers from a private
|
|
7
|
+
// copy named by their content: an update leaves it alone, the pins keep
|
|
8
|
+
// guarding it, and the next process takes a copy of the new release.
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
13
|
+
|
|
14
|
+
const KEEP_MS = 30 * 24 * 60 * 60 * 1000;
|
|
15
|
+
const hash = file => createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
16
|
+
const cacheRoot = home => path.join(home, process.platform === 'darwin' ? 'Library/Caches/Piagent/runtime' : '.cache/piagent/runtime');
|
|
17
|
+
|
|
18
|
+
function runtimeFiles(source) {
|
|
19
|
+
const files = fs.readdirSync(source, { withFileTypes: true }).filter(entry => entry.isFile() && entry.name.endsWith('.mjs')).map(entry => entry.name);
|
|
20
|
+
const walk = relative => {
|
|
21
|
+
let entries;
|
|
22
|
+
try { entries = fs.readdirSync(path.join(source, relative), { withFileTypes: true }); } catch { return; }
|
|
23
|
+
for (const entry of entries) {
|
|
24
|
+
const name = path.join(relative, entry.name);
|
|
25
|
+
if (entry.isDirectory()) walk(name); else if (entry.isFile()) files.push(name);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
walk(path.join('dist', 'workspace'));
|
|
29
|
+
return files.sort();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const intact = (directory, listing) => listing.every(([file, digest]) => { try { return hash(path.join(directory, file)) === digest; } catch { return false; } });
|
|
33
|
+
|
|
34
|
+
// The copy of `source` under the member's cache folder; an old one is removed
|
|
35
|
+
// once no process has asked for it for a month.
|
|
36
|
+
export function snapshotRuntime(source, { home = os.homedir(), now = Date.now() } = {}) {
|
|
37
|
+
const listing = runtimeFiles(source).map(file => [file, hash(path.join(source, file))]);
|
|
38
|
+
const id = createHash('sha256').update(JSON.stringify(listing)).digest('hex').slice(0, 32);
|
|
39
|
+
const root = cacheRoot(home), target = path.join(root, id);
|
|
40
|
+
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
41
|
+
if (!intact(target, listing)) {
|
|
42
|
+
const building = path.join(root, `.${id}.${randomUUID()}`);
|
|
43
|
+
try {
|
|
44
|
+
for (const [file] of listing) {
|
|
45
|
+
fs.mkdirSync(path.dirname(path.join(building, file)), { recursive: true, mode: 0o700 });
|
|
46
|
+
fs.copyFileSync(path.join(source, file), path.join(building, file));
|
|
47
|
+
fs.chmodSync(path.join(building, file), 0o400);
|
|
48
|
+
}
|
|
49
|
+
// Without it the workspace worker's .js files would load as CommonJS.
|
|
50
|
+
fs.writeFileSync(path.join(building, 'package.json'), '{"type":"module"}\n', { mode: 0o400 });
|
|
51
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
52
|
+
// Another session may have built the same copy meanwhile.
|
|
53
|
+
try { fs.renameSync(building, target); } catch (error) { if (!intact(target, listing)) throw error; }
|
|
54
|
+
} finally { fs.rmSync(building, { recursive: true, force: true }); }
|
|
55
|
+
}
|
|
56
|
+
fs.utimesSync(target, new Date(now), new Date(now));
|
|
57
|
+
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
58
|
+
if (!entry.isDirectory() || entry.name === id) continue;
|
|
59
|
+
const old = path.join(root, entry.name);
|
|
60
|
+
try { if (now - fs.statSync(old).mtimeMs > KEEP_MS) fs.rmSync(old, { recursive: true, force: true }); } catch { /* another session removes it */ }
|
|
61
|
+
}
|
|
62
|
+
return fs.realpathSync(target);
|
|
63
|
+
}
|
|
@@ -9,18 +9,19 @@ import { companyTerminalRuntime } from './terminal-runtime.mjs';
|
|
|
9
9
|
import { terminalChannel } from './terminal-channel.mjs';
|
|
10
10
|
import { SEATBELT_TERMINAL_RULES, seatbeltTerminal } from './terminal-seatbelt.mjs';
|
|
11
11
|
|
|
12
|
-
const
|
|
12
|
+
const installedWorker = fileURLToPath(new URL('./terminal-worker.mjs', import.meta.url));
|
|
13
13
|
const hash = file => createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
14
14
|
const inside = (file, root) => file === root || file.startsWith(root + path.sep);
|
|
15
15
|
const dimensions = ({ cols, rows }) => Number.isInteger(cols) && cols >= 2 && cols <= 500 && Number.isInteger(rows) && rows >= 1 && rows <= 200;
|
|
16
16
|
|
|
17
17
|
export class BoundaryTerminals {
|
|
18
|
-
#boundary; #runtime; #workerDigest; #entries = new Map(); #closed = false;
|
|
18
|
+
#boundary; #runtime; #worker; #workerDigest; #entries = new Map(); #closed = false;
|
|
19
19
|
reasonCode = null;
|
|
20
20
|
constructor(boundary) {
|
|
21
21
|
this.#boundary = boundary;
|
|
22
22
|
try {
|
|
23
23
|
this.#runtime = companyTerminalRuntime();
|
|
24
|
+
const worker = this.#worker = boundary.runtimeFile?.('terminal-worker.mjs') ?? installedWorker; // the session's own copy (runtime-snapshot.mjs)
|
|
24
25
|
if (inside(this.#runtime.root, boundary.cwd) || inside(worker, boundary.cwd)) throw Error('managed-terminal-runtime-overlap');
|
|
25
26
|
this.#workerDigest = hash(worker);
|
|
26
27
|
} catch (error) { this.reasonCode = /^managed-terminal-/.test(error.message) ? error.message : 'managed-terminal-runtime-invalid'; }
|
|
@@ -33,6 +34,7 @@ export class BoundaryTerminals {
|
|
|
33
34
|
if (!dimensions(size)) throw Error('managed-terminal-invalid-size');
|
|
34
35
|
if (this.#entries.size >= 4) throw Error('managed-terminal-limit');
|
|
35
36
|
this.#runtime.assertCurrent();
|
|
37
|
+
const worker = this.#worker;
|
|
36
38
|
if (hash(worker) !== this.#workerDigest) throw Error('managed-terminal-runtime-changed');
|
|
37
39
|
for (const [file, digest] of boundary.pins) if (hash(file) !== digest) throw Error('managed-runtime-changed');
|
|
38
40
|
const temporary = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'piagent-terminal-')));
|
|
@@ -3,7 +3,7 @@ import { SessionMutationObservations } from "./session-mutation-observations.mjs
|
|
|
3
3
|
import { BoundaryTerminals } from './terminal-boundary.mjs';
|
|
4
4
|
import { ToolFreshness } from './tool-freshness.mjs';
|
|
5
5
|
import { BoundaryQueue } from './boundary-queue.mjs';
|
|
6
|
-
import { workspaceWorkerPins, verifyWorkspaceWorker } from './workspace-worker-pins.mjs';
|
|
6
|
+
import { workspaceWorkerPins, verifyWorkspaceWorker } from './workspace-worker-pins.mjs'; import { snapshotRuntime } from './runtime-snapshot.mjs';
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import os from 'node:os';
|
|
9
9
|
import path from 'node:path';
|
|
@@ -202,7 +202,7 @@ export class ManagedToolBoundary {
|
|
|
202
202
|
get sourceControlRevision() { return this.#sourceRequests.revision; }
|
|
203
203
|
source(request, binding) { return this.invoke("workspace", this.#sourceRequests.request(request, binding)); }
|
|
204
204
|
#mutationObservations = new SessionMutationObservations();
|
|
205
|
-
mutationObservation(file) { return this.#mutationObservations.get(file); }
|
|
205
|
+
mutationObservation(file) { return this.#mutationObservations.get(file); } runtimeFile(name) { return path.join(this.runtimeRoot, name); }
|
|
206
206
|
#children = new Set();
|
|
207
207
|
#closed = false;
|
|
208
208
|
// readOnly: the project cannot be written. commands: bash is offered all
|
|
@@ -214,16 +214,16 @@ export class ManagedToolBoundary {
|
|
|
214
214
|
if (process.platform !== 'darwin' && !(this.linux && BWRAP)) throw new Error('managed-sandbox-unavailable');
|
|
215
215
|
this.cwd = fs.realpathSync(cwd); this.sdkRoot = fs.realpathSync(sdkRoot);
|
|
216
216
|
this.node = fs.realpathSync(node);
|
|
217
|
-
this.
|
|
217
|
+
this.sourceRoot = fs.realpathSync(path.dirname(workerPath)); this.runtimeRoot = snapshotRuntime(this.sourceRoot, { home: home ?? os.homedir() }); this.workerPath = this.runtimeFile('tool-worker.mjs'); // an update leaves a running session's workers alone
|
|
218
218
|
this.workspacePins = workspaceWorkerPins(this.runtimeRoot);
|
|
219
219
|
this.sdkPath = path.join(this.sdkRoot, 'dist/index.js');
|
|
220
220
|
this.git = managedGit();
|
|
221
221
|
if (JSON.parse(fs.readFileSync(path.join(this.sdkRoot, 'package.json'), 'utf8')).version !== PI_HOST_VERSION) throw new Error('managed-sdk-version-unqualified');
|
|
222
222
|
const workerModules = ['review-snapshot-worker.mjs', 'tool-freshness.mjs', 'search-fallback.mjs', 'sandbox-diagnostic.mjs', 'ts-diagnostics-server.mjs', 'workspace-worker-pins.mjs'].map(name => path.join(this.runtimeRoot, name));
|
|
223
|
-
this.pins = new Map([this.node, this.git, workerPath, ...workerModules, this.sdkPath].map(file => [file, hash(file)]));
|
|
223
|
+
this.pins = new Map([this.node, this.git, this.workerPath, ...workerModules, this.sdkPath].map(file => [file, hash(file)]));
|
|
224
224
|
const userHome = fs.realpathSync(home ?? os.homedir());
|
|
225
|
-
if ([userHome, '/', this.sdkRoot, this.runtimeRoot].includes(this.cwd)
|
|
226
|
-
|| [this.sdkRoot,this.runtimeRoot,this.node,this.git,...['/opt/homebrew/bin/gh','/usr/local/bin/gh'].filter(p=>fs.existsSync(p)).map(p=>fs.realpathSync(p))].some(p=>inside(p,this.cwd))) throw new Error('managed-project-overlaps-runtime');
|
|
225
|
+
if ([userHome, '/', this.sdkRoot, this.runtimeRoot, this.sourceRoot].includes(this.cwd)
|
|
226
|
+
|| [this.sdkRoot,this.runtimeRoot,this.sourceRoot,this.node,this.git,...['/opt/homebrew/bin/gh','/usr/local/bin/gh'].filter(p=>fs.existsSync(p)).map(p=>fs.realpathSync(p))].some(p=>inside(p,this.cwd))) throw new Error('managed-project-overlaps-runtime');
|
|
227
227
|
this.temporary = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'piagent-tools-')));
|
|
228
228
|
fs.chmodSync(this.temporary, 0o700);
|
|
229
229
|
this.home = path.join(this.temporary, 'home'); fs.mkdirSync(this.home, { mode: 0o700 });
|
|
@@ -371,7 +371,7 @@ export class ManagedToolBoundary {
|
|
|
371
371
|
guard?.();
|
|
372
372
|
const [sandbox, sandboxArgs] = this.linux ? [BWRAP, this.bubblewrap(network, docker)]
|
|
373
373
|
: ['/usr/bin/sandbox-exec', ['-p', docker ? this.dockerProfile : network ? this.networkProfile : isolated ? this.isolatedProfile : this.profile]];
|
|
374
|
-
const child = spawn(sandbox, [...sandboxArgs, this.node, workerPath, this.sdkPath, this.cwd], {
|
|
374
|
+
const child = spawn(sandbox, [...sandboxArgs, this.node, this.workerPath, this.sdkPath, this.cwd], {
|
|
375
375
|
cwd: this.cwd, detached: true, stdio: ['pipe', 'pipe', 'pipe'],
|
|
376
376
|
env: docker ? this.environment.docker : network ? this.environment.network : this.environment.offline,
|
|
377
377
|
});
|
|
@@ -21,7 +21,7 @@ export class TsDiagnostics {
|
|
|
21
21
|
if (this.#child) return this.#child;
|
|
22
22
|
if (this.#starts >= RESTARTS) { this.#off = true; return null; }
|
|
23
23
|
this.#starts += 1;
|
|
24
|
-
const { child } = await this.boundary.spawnSandboxed([this.boundary.node, SERVER, this.boundary.cwd], randomUUID(), { stdin: 'pipe' });
|
|
24
|
+
const { child } = await this.boundary.spawnSandboxed([this.boundary.node, this.boundary.runtimeFile?.('ts-diagnostics-server.mjs') ?? SERVER, this.boundary.cwd], randomUUID(), { stdin: 'pipe' });
|
|
25
25
|
child.stdout.on('data', chunk => {
|
|
26
26
|
this.#buffer += chunk.toString('utf8');
|
|
27
27
|
for (let at; (at = this.#buffer.indexOf('\n')) >= 0;) {
|