@piagent/platform 1.36.1 → 1.37.1

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.
Files changed (92) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +1 -1
  3. package/README.vi.md +1 -1
  4. package/docs/command-reference-vietnamese.md +2 -2
  5. package/docs/distribution-standard.md +6 -6
  6. package/docs/languages.json +2 -2
  7. package/docs/operator-manual-vietnamese.md +7 -7
  8. package/docs/publishing-for-teams.md +2 -0
  9. package/docs/release-install-policy.md +2 -2
  10. package/docs/team-onboarding.md +2 -2
  11. package/docs-site/architecture.html +1 -1
  12. package/docs-site/benchmark.html +1 -1
  13. package/docs-site/commands.html +1 -1
  14. package/docs-site/content/en/index.html +4 -4
  15. package/docs-site/content/en/whats-new.html +12 -0
  16. package/docs-site/content/index.html +4 -4
  17. package/docs-site/content/whats-new.html +12 -0
  18. package/docs-site/context-engine.html +1 -1
  19. package/docs-site/ecosystem.html +1 -1
  20. package/docs-site/en/architecture.html +1 -1
  21. package/docs-site/en/benchmark.html +1 -1
  22. package/docs-site/en/commands.html +1 -1
  23. package/docs-site/en/context-engine.html +1 -1
  24. package/docs-site/en/ecosystem.html +1 -1
  25. package/docs-site/en/index.html +5 -5
  26. package/docs-site/en/mcp-approval.html +1 -1
  27. package/docs-site/en/mcp-auth.html +1 -1
  28. package/docs-site/en/mcp-servers.html +1 -1
  29. package/docs-site/en/mcp.html +1 -1
  30. package/docs-site/en/permissions.html +1 -1
  31. package/docs-site/en/profiles.html +1 -1
  32. package/docs-site/en/quickstart.html +1 -1
  33. package/docs-site/en/runtime.html +1 -1
  34. package/docs-site/en/scope.html +1 -1
  35. package/docs-site/en/security.html +1 -1
  36. package/docs-site/en/team.html +1 -1
  37. package/docs-site/en/whats-new.html +13 -1
  38. package/docs-site/en/windows.html +1 -1
  39. package/docs-site/en/workflows.html +1 -1
  40. package/docs-site/index.html +5 -5
  41. package/docs-site/mcp-approval.html +1 -1
  42. package/docs-site/mcp-auth.html +1 -1
  43. package/docs-site/mcp-servers.html +1 -1
  44. package/docs-site/mcp.html +1 -1
  45. package/docs-site/permissions.html +1 -1
  46. package/docs-site/profiles.html +1 -1
  47. package/docs-site/quickstart.html +1 -1
  48. package/docs-site/runtime.html +1 -1
  49. package/docs-site/scope.html +1 -1
  50. package/docs-site/security.html +1 -1
  51. package/docs-site/team.html +1 -1
  52. package/docs-site/whats-new.html +13 -1
  53. package/docs-site/windows.html +1 -1
  54. package/docs-site/workflows.html +1 -1
  55. package/evals/long-task-soak-v1/README.md +96 -0
  56. package/evals/long-task-soak-v1/lib/judge.mjs +67 -0
  57. package/evals/long-task-soak-v1/lib/persona.mjs +63 -0
  58. package/evals/long-task-soak-v1/lib/report.mjs +35 -0
  59. package/evals/long-task-soak-v1/lib/session-loop.mjs +165 -0
  60. package/evals/long-task-soak-v1/lib/workspace.mjs +115 -0
  61. package/evals/long-task-soak-v1/runner.mjs +89 -0
  62. package/evals/long-task-soak-v1/scenarios/autonomous-node-inventory.json +15 -0
  63. package/evals/long-task-soak-v1/scenarios/business-python-billing.json +34 -0
  64. package/evals/long-task-soak-v1/scenarios/framework-go-docker.json +40 -0
  65. package/evals/long-task-soak-v1/scenarios/framework-playwright-e2e.json +15 -0
  66. package/evals/long-task-soak-v1/scenarios/harness-guardrails-secrets.json +23 -0
  67. package/evals/long-task-soak-v1/scenarios/long-horizon-polyglot-rename.json +30 -0
  68. package/evals/long-task-soak-v1/scenarios/long-node-inventory-platform.json +64 -0
  69. package/evals/long-task-soak-v1/scenarios/long-polyglot-platform.json +288 -0
  70. package/evals/long-task-soak-v1/scenarios/long-postgres-booking-platform.json +210 -0
  71. package/evals/long-task-soak-v1/scenarios/long-python-billing-platform.json +84 -0
  72. package/evals/long-task-soak-v1/scenarios/mixed-requirement-change.json +20 -0
  73. package/evals/long-task-soak-v1/scenarios/stability-flaky-slow-suite.json +22 -0
  74. package/evals/long-task-soak-v1/scenarios/thirdparty-postgres-integration.json +30 -0
  75. package/evals/long-task-soak-v1/surfaces/terminal-rpc.mjs +150 -0
  76. package/evals/long-task-soak-v1/surfaces/webui-gateway.mjs +214 -0
  77. package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
  78. package/package.json +2 -1
  79. package/packages/piagent-core/managed/helper-run.mjs +11 -1
  80. package/packages/piagent-core/managed/helper-settlement.mjs +17 -6
  81. package/packages/piagent-core/managed/ip-policy.mjs +50 -0
  82. package/packages/piagent-core/managed/memory-lessons.mjs +4 -1
  83. package/packages/piagent-core/managed/memory.mjs +1 -1
  84. package/packages/piagent-core/managed/package-proxy.mjs +12 -14
  85. package/packages/piagent-core/managed/session.mjs +4 -5
  86. package/packages/piagent-core/managed/skill-feedback.mjs +9 -1
  87. package/packages/piagent-core/managed/studio-link.mjs +16 -0
  88. package/packages/piagent-core/managed/web-fetch.mjs +3 -14
  89. package/packages/piagent-core/package.json +1 -1
  90. package/packages/piagent-core/runtime/managed-failure.mjs +5 -0
  91. package/scripts/company-terminal.mjs +2 -1
  92. package/scripts/piagent-studio.mjs +13 -3
@@ -0,0 +1,84 @@
1
+ {
2
+ "id": "long-python-billing-platform",
3
+ "title": "A long session building a billing platform in six phases (v1, dunning, multi-currency, a 3,500-line legacy refactor, SQLite import of 15,000 records, CLI) with a rule change, two Stops and a Gateway restart along the way",
4
+ "category": ["long-horizon", "business", "python", "mixed"],
5
+ "language": "en",
6
+ "fixture": "python-billing-long",
7
+ "requires": ["uv", "network"],
8
+ "prompt": "We have a long day on the billing package. First piece: please implement the whole of SPEC.md: money rounding, coupons, tax, invoices, subscriptions and billing cycles, mid-cycle plan changes, refunds and the CSV/JSON export, all under src/billing/. Add tests for every section under tests/ and run `.venv/bin/python -m pytest -q` until everything passes. A few business rules in the spec are still marked TBD; I'm the product owner, so ask me about those, and don't guess. Everything else in the spec is decided, so carry on through to the end without stopping. The documents under docs/ are the next pieces of work; I'll bring them up one at a time.",
9
+ "phases": [
10
+ {
11
+ "id": "p2",
12
+ "prompt": "Thanks, v1 is in. Next piece: implement docs/DUNNING.md, failed payments as a state machine with the retry schedule, the grace period and suspension, with tests for every rule. One rule in there is still open and only I can decide it, so ask me; don't guess. Everything from before must keep passing (`.venv/bin/python -m pytest -q`). Carry on to the end without stopping.",
13
+ "oracle": { "hidden": "hidden-p2", "checks": [{ "name": "dunning acceptance with the product owner's answer, plus everything before", "run": ".venv/bin/python -m pytest -q", "timeoutSec": 600 }] },
14
+ "reference": "reference-p2"
15
+ },
16
+ {
17
+ "id": "p3",
18
+ "prompt": "Good. Next: multi-currency billing as described in docs/MULTICURRENCY.md, with the exchange rates in data/fx_rates.csv (don't edit that file). One business rule there is still open; ask me about it before you implement it. Add tests for every rule, keep the whole suite green and carry on to the end.",
19
+ "oracle": { "hidden": "hidden-p3", "checks": [{ "name": "multi-currency acceptance with the product owner's answer, plus everything before", "run": ".venv/bin/python -m pytest -q", "timeoutSec": 600 }] },
20
+ "reference": "reference-p3"
21
+ },
22
+ {
23
+ "id": "p4",
24
+ "prompt": "Next is the big one. src/billing/legacy_reports.py (about 3,500 lines) has to become a proper package, as planned in docs/REPORTS.md: split by topic, the shared logic written once, full type hints, and every report byte-for-byte identical to the goldens in tests/golden/reports. Finance diff these files, so not a single character of output may change. Read the legacy code carefully before you cut it up. Keep the full suite green and carry on until it's done.",
25
+ "oracle": { "hidden": "hidden-p4", "checks": [{ "name": "reports package: golden outputs, structure, type hints, plus everything before", "run": ".venv/bin/python -m pytest -q", "timeoutSec": 600 }] },
26
+ "reference": "reference-p4"
27
+ },
28
+ {
29
+ "id": "p5",
30
+ "prompt": "Next: persistence per docs/PERSISTENCE.md: the SQLite repository, schema migrations v1 to v2 including the data migration, and the import of data/invoices_2025.jsonl with its error report. The old export has some bad lines; the import must report them, not choke on them, and don't edit the data file. Everything is decided in that document. Tests for every rule, full suite green, carry on to the end.",
31
+ "oracle": { "hidden": "hidden-p5", "checks": [{ "name": "storage, migrations and the 2025 import, plus everything before", "run": ".venv/bin/python -m pytest -q", "timeoutSec": 600 }] },
32
+ "reference": "reference-p5"
33
+ },
34
+ {
35
+ "id": "p6",
36
+ "prompt": "Last piece for today: the `python -m billing` command line from docs/CLI.md (migrate, import, report with every report name, export), documented in README.md with examples. Then do a final regression pass over everything we did today (SPEC.md, the dunning, currency, reports, persistence and CLI documents, and the rule changes I gave you along the way) and make sure the whole suite passes.",
37
+ "oracle": { "hidden": "hidden-p6", "checks": [{ "name": "CLI, README, the invoice-number rule change, and a full regression", "run": ".venv/bin/python -m pytest -q", "timeoutSec": 900 }] },
38
+ "reference": "reference-p6"
39
+ }
40
+ ],
41
+ "questionsExpected": false,
42
+ "decisions": [
43
+ {
44
+ "id": "suspension-access", "mustAsk": true,
45
+ "topic": ["suspen", "(lose|loses|losing|keep|keeps|keeping|retain|retains) access", "access.{0,40}(immediate|right away|period end|end of)", "đình chỉ", "tạm ngưng", "khóa (tài khoản|dịch vụ)", "mất quyền", "quyền truy cập"],
46
+ "option": "^\\W*(access\\s+)?(ends?\\s+|stops?\\s+|lost\\s+|revoked\\s+|cut( off)?\\s+)?immediate|^\\W*(revoke|cut|end|block|stop|remove|lose)\\w*\\b[^.]{0,40}\\b(immediately|at once|right away|on suspension)|^\\W*(ngay|chấm dứt ngay|khóa ngay|mất quyền)",
47
+ "reply": "When a subscription is suspended the customer loses access immediately, from the suspension date on. They do not keep access until the end of the period they already paid for. Paying the overdue invoice gives access back."
48
+ },
49
+ {
50
+ "id": "refund-fx-date", "mustAsk": true,
51
+ "topic": ["(fx|exchange|currency|conversion).{0,60}refund", "refund.{0,60}(fx|exchange|currency|conver)", "tỷ giá", "quy đổi"],
52
+ "option": "^\\W*(use\\s+)?(the\\s+)?(original|invoice|issue)|^\\W*(rate|fx rate|exchange rate)\\s+(at|on|of)\\s+(the\\s+)?(original|invoice|issue)|^\\W*(tỷ giá\\s+)?(ngày\\s+)?(gốc|hóa đơn|xuất hóa đơn)",
53
+ "reply": "Convert a refund at the rate of the original invoice date (the invoice's fx_rate), not at the rate on the refund date, so a full refund cancels the invoice exactly in the USD books."
54
+ },
55
+ {
56
+ "id": "upgrade-charge", "mustAsk": true,
57
+ "topic": ["prorat", "upgrade", "mid.?cycle", "plan change", "change.{0,10}plan", "giữa kỳ", "nâng cấp", "đổi gói", "tính theo ngày"],
58
+ "option": "full (new )?month|full monthly|full.{0,20}new plan|whole month|cả tháng|nguyên tháng",
59
+ "reply": "On an upgrade, charge the full monthly price of the new plan immediately: no proration and no credit for the old plan. Taxed as usual, no coupon."
60
+ },
61
+ {
62
+ "id": "rounding", "mustAsk": true,
63
+ "topic": ["round", "half.?(up|even)", "banker", "làm tròn"],
64
+ "option": "half.?even|banker|chẵn",
65
+ "reply": "Round half-even (banker's rounding) to 2 decimal places, everywhere we round."
66
+ },
67
+ {
68
+ "id": "coupon-vs-tax", "mustAsk": true,
69
+ "topic": ["coupon", "discount", "(before|after).{0,15}tax", "tax.{0,20}(before|after|coupon|discount)", "giảm giá", "khuyến mãi", "mã giảm", "trước thuế", "sau thuế"],
70
+ "option": "^\\W*(apply\\s+|(the\\s+)?(coupons?|discounts?)\\s+)*after.{0,10}tax|post.?tax|^\\W*sau thuế",
71
+ "reply": "Apply the coupon after tax: compute tax on the full subtotal, then take the discount from subtotal + tax."
72
+ }
73
+ ],
74
+ "approvals": "allow",
75
+ "userActions": [
76
+ { "when": { "minutes": 10 }, "do": "steer", "text": "One rule change from finance, please fold it in alongside whatever you are doing: invoice numbers must follow the format INV-YYYY-NNNNN, where YYYY is the year of the invoice's issue date and NNNNN is a five-digit sequence in issue order, per service, that restarts at 00001 every calendar year (INV-2026-00001, INV-2026-00002, ..., INV-2027-00001). Credit notes keep CN-0001. Update the code, the tests and SPEC.md §6." },
77
+ { "when": { "minutes": 28 }, "do": "stop_then_continue", "text": "Sorry, I pressed Stop by accident. Please carry on from where you were." },
78
+ { "when": { "minutes": 48 }, "do": "restart", "hard": true, "text": "Piagent just updated and restarted, please carry on." },
79
+ { "when": { "minutes": 70 }, "do": "stop_then_continue", "text": "I stopped you to check something; all good. Please continue where you left off." }
80
+ ],
81
+ "budget": { "maxMinutes": 150, "maxNudges": 4, "stallMinutes": 15 },
82
+ "oracle": { "hidden": "hidden-main", "checks": [{ "name": "SPEC v1 acceptance with the product owner's answers", "run": ".venv/bin/python -m pytest -q", "timeoutSec": 600 }] },
83
+ "reference": "reference-main"
84
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "id": "mixed-requirement-change",
3
+ "title": "The member changes a rule mid-task, presses Stop, and reopens the conversation",
4
+ "category": ["mixed", "interruption", "long-horizon"],
5
+ "language": "vi",
6
+ "fixture": "node-inventory",
7
+ "requires": [],
8
+ "prompt": "Cài đặt đầy đủ SPEC.md của kho-hang (src/ và test/), chạy npm test đến khi qua hết.",
9
+ "questionsExpected": false,
10
+ "decisions": [],
11
+ "approvals": "allow",
12
+ "userActions": [
13
+ { "when": { "toolCalls": 12 }, "do": "steer", "text": "À anh đổi một quy tắc: lowStock(threshold) phải tính cả SKU có available đúng bằng threshold (<=, không phải <). Cập nhật code, test và SPEC.md cho khớp nhé." },
14
+ { "when": { "toolCalls": 25 }, "do": "stop_then_continue", "text": "Anh lỡ bấm dừng, em làm tiếp phần còn lại nhé." },
15
+ { "when": { "toolCalls": 40 }, "do": "restart", "text": "Anh vừa mở lại cửa sổ. Làm tiếp cho xong nhé." }
16
+ ],
17
+ "budget": { "maxMinutes": 75, "maxNudges": 3, "stallMinutes": 12 },
18
+ "oracle": { "hidden": "hidden-change", "checks": [{ "name": "spec acceptance with the changed rule", "run": "node --test 'test/*.test.js'", "timeoutSec": 300 }] },
19
+ "reference": "reference-change"
20
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "id": "stability-flaky-slow-suite",
3
+ "title": "Fix a flaky test at its root in a suite that takes ~2.7 minutes, and prove it with a deterministic regression test",
4
+ "category": ["stability", "long-running-commands", "autonomous"],
5
+ "language": "en",
6
+ "fixture": "flaky-slow-suite",
7
+ "requires": [],
8
+ "prompt": "Our CI keeps flaking on \"withRetry gives up before crossing the deadline (real timers)\" in ingest-relay; details are in ISSUE.md and the contract is docs/retry.md. Please find the root cause in src/ and fix it properly (no retries, skips or loosened assertions), add the `random` option, write a deterministic regression test that would fail on the old code, and run the whole suite green at least twice. Heads up: npm test takes about 3 minutes on purpose, let it finish. No need to check back with me, just get it done.",
9
+ "questionsExpected": false,
10
+ "decisions": [],
11
+ "approvals": "allow",
12
+ "budget": { "maxMinutes": 60, "maxNudges": 3, "stallMinutes": 10 },
13
+ "oracle": {
14
+ "hidden": "hidden",
15
+ "checks": [
16
+ { "name": "no skip/todo/only or retry wrappers, soak suite and real-timer test kept, regression test injects random", "run": "node .soak/check_static.mjs", "timeoutSec": 60, "stopOnFail": false },
17
+ { "name": "deterministic retry contract: injected random, one draw per retry, never past the deadline (seeded sweep)", "run": "node --test .soak/retry-regression.hidden.test.mjs", "timeoutSec": 120 },
18
+ { "name": "full suite once (about 2.7 minutes of real timers)", "run": "node --test 'test/*.test.js'", "timeoutSec": 600 }
19
+ ]
20
+ },
21
+ "reference": "reference"
22
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "id": "thirdparty-postgres-integration",
3
+ "title": "Meeting-room booking service on real PostgreSQL: migrations, concurrency-safe overlap checks, soft cancel, keyset pagination",
4
+ "category": ["third-party", "business", "integration", "node"],
5
+ "language": "vi",
6
+ "fixture": "node-postgres",
7
+ "requires": ["docker", "network"],
8
+ "prompt": "Em làm giúp anh toàn bộ SPEC.md cho dịch vụ đặt lịch phòng họp nhé: thêm migration mới, hoàn thiện repository (phòng họp và đặt lịch), chống trùng lịch an toàn khi nhiều người đặt cùng lúc, huỷ đặt lịch, danh sách có phân trang, và viết test tích hợp chạy với PostgreSQL thật (dùng scripts/db-up.sh hoặc docker-compose.yml). Chỗ nào SPEC ghi \"cần chủ sản phẩm chốt\" thì hỏi anh trước khi làm phần đó, còn lại em cứ tự làm đến khi npm run migrate && npm test qua hết. Xong thì dừng container Postgres em đã bật.",
9
+ "questionsExpected": false,
10
+ "decisions": [
11
+ {
12
+ "id": "cancel-soft-delete",
13
+ "mustAsk": true,
14
+ "topic": ["hu[ỷy]", "h[ủu]y", "cancel", "x[oó][aáà]", "delete", "soft", "xoá mềm|xóa mềm|xoá hẳn|xóa hẳn"],
15
+ "option": "soft|mềm|cancelled_at|giữ lại|đánh dấu|mark",
16
+ "reply": "Huỷ là xoá mềm: giữ lại bản ghi và đặt cancelled_at = thời điểm huỷ (cột cancelled_at trong bảng bookings). Đặt lịch đã huỷ không tính khi kiểm tra trùng và không hiện trong listBookings mặc định, nhưng listBookings({ includeCancelled: true }) vẫn phải trả về chúng (kèm cancelledAt), getBooking vẫn trả về được, và huỷ lần nữa thì báo ALREADY_CANCELLED."
17
+ },
18
+ {
19
+ "id": "back-to-back",
20
+ "mustAsk": true,
21
+ "topic": ["li[eề]n k[eề]", "liền nhau|nối tiếp|sát nhau", "back.?to.?back", "adjacent", "end.*start", "kết thúc.*bắt đầu", "ch[aạ]m", "\\[\\s*\\)|half.?open|nửa mở"],
22
+ "option": "không (bị |được )?(tính là |coi là )?(trùng|xung đột)|cho phép|allow|not (a )?conflict|no conflict|don.?t conflict|half.?open|\\[\\)",
23
+ "reply": "Hai lịch liền kề (giờ kết thúc lịch này bằng giờ bắt đầu lịch kia, ví dụ 09:00–10:00 và 10:00–11:00) KHÔNG tính là trùng, phải cho phép đặt. Chỉ khi hai khoảng thật sự giao nhau mới là BOOKING_CONFLICT."
24
+ }
25
+ ],
26
+ "approvals": "allow",
27
+ "budget": { "maxMinutes": 75, "maxNudges": 3, "stallMinutes": 15 },
28
+ "oracle": { "hidden": "hidden", "checks": [{ "name": "spec acceptance on a throwaway PostgreSQL", "run": "bash scripts/oracle.sh", "timeoutSec": 600 }] },
29
+ "reference": "reference"
30
+ }
@@ -0,0 +1,150 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawn, execFileSync } from 'node:child_process';
4
+ import { piagentEnv } from '../lib/workspace.mjs';
5
+
6
+ // The company Terminal (`piagent studio --rpc`) driven as the member: one
7
+ // child process per conversation, Pi's RPC protocol on stdin/stdout.
8
+ const SKIP = 'Để agent tự quyết';
9
+ const OTHER = /^\d+\. Khác \(tự nhập\)$/;
10
+ const ALLOW = /^(?:allow|cho phép|đồng ý|yes|có|chạy|run)/i, DENY = /^(?:block|deny|từ chối|no|không|chặn)/i;
11
+
12
+ export function terminalSupportsRpc(piagent = 'piagent') {
13
+ try { return execFileSync(piagent, ['studio', '--help'], { encoding: 'utf8', env: piagentEnv() }).includes('--rpc'); }
14
+ catch { return false; }
15
+ }
16
+
17
+ // "(1/2) Header: question" and "1. Label — description" as ask_user shows them.
18
+ function parseQuestion(title, options) {
19
+ const question = title.replace(/^\(\d+\/\d+\)\s*/, '').split('\nĐã chọn:')[0];
20
+ const real = options.filter(o => o !== SKIP && !OTHER.test(o) && o !== 'Xong');
21
+ return { question, options: real.map(o => { const [label, ...rest] = o.replace(/^\d+\.\s*/, '').split(' — '); return { label, description: rest.join(' — ') }; }), raw: real };
22
+ }
23
+
24
+ export class TerminalSurface {
25
+ constructor({ piagent = 'piagent' } = {}) { this.piagent = piagent; this.seq = 0; }
26
+
27
+ async open({ project, runDir, hooks }) {
28
+ Object.assign(this, { project, runDir, hooks });
29
+ this.stderrLog = fs.createWriteStream(path.join(runDir, 'terminal-stderr.log'), { flags: 'a' });
30
+ await this.#spawn();
31
+ }
32
+
33
+ async #spawn() {
34
+ const child = spawn(this.piagent, ['studio', '--rpc', '--project', this.project], { env: piagentEnv(), stdio: ['pipe', 'pipe', 'pipe'] });
35
+ this.child = child; this.pending = new Map(); this.closing = false; this.stderr = '';
36
+ let buffer = Buffer.alloc(0);
37
+ child.stdout.on('data', chunk => {
38
+ buffer = Buffer.concat([buffer, chunk]);
39
+ for (let i; (i = buffer.indexOf(10)) >= 0;) {
40
+ const line = buffer.subarray(0, i).toString('utf8').replace(/\r$/, ''); buffer = buffer.subarray(i + 1);
41
+ if (!line.trim()) continue;
42
+ let record; try { record = JSON.parse(line); } catch { continue; }
43
+ this.#onRecord(record);
44
+ }
45
+ });
46
+ child.stderr.on('data', d => { this.stderr = (this.stderr + d).slice(-4000); this.stderrLog.write(d); });
47
+ child.once('exit', (code, signal) => {
48
+ for (const p of this.pending.values()) p.reject(Error(`terminal exited ${code ?? signal}`));
49
+ this.pending.clear();
50
+ if (!this.closing && this.turn) this.#endTurn({ crashed: true, error: `exit ${code ?? signal}: ${this.stderr.slice(-800)}` });
51
+ });
52
+ child.stdin.on('error', () => { /* the exit handler reports it */ });
53
+ await this.#command({ type: 'get_state' }, 180_000).then(s => { this.sessionFile = s?.sessionFile ?? this.sessionFile; });
54
+ }
55
+
56
+ #command(command, timeoutMs = 60_000) {
57
+ const id = `soak-${++this.seq}`;
58
+ return new Promise((resolve, reject) => {
59
+ const timer = setTimeout(() => { this.pending.delete(id); reject(Error(`no response to ${command.type}`)); }, timeoutMs);
60
+ this.pending.set(id, { resolve: v => { clearTimeout(timer); resolve(v); }, reject: e => { clearTimeout(timer); reject(e); } });
61
+ this.child.stdin.write(JSON.stringify({ ...command, id }) + '\n');
62
+ });
63
+ }
64
+ #reply(record) { this.child.stdin.write(JSON.stringify(record) + '\n'); }
65
+
66
+ #onRecord(r) {
67
+ const hooks = this.hooks;
68
+ if (r.type === 'response') {
69
+ const p = this.pending.get(r.id); if (!p) return; this.pending.delete(r.id);
70
+ return r.success ? p.resolve(r.data) : p.reject(Error(`${r.command}: ${r.error}`));
71
+ }
72
+ if (r.type === 'extension_ui_request') return this.#onUi(r);
73
+ if (!this.turn) return;
74
+ if (r.type === 'message_update' && r.assistantMessageEvent?.type === 'text_delta') hooks.onEvent({ type: 'text_delta' });
75
+ else if (r.type === 'message_end' && r.message?.role === 'assistant') {
76
+ const m = r.message, text = (m.content ?? []).filter(c => c.type === 'text').map(c => c.text).join('\n');
77
+ if (text.trim()) this.turn.lastText = text;
78
+ if (m.stopReason === 'error') this.turn.error = m.errorMessage ?? 'error'; else if (text.trim()) this.turn.error = null;
79
+ hooks.onEvent({ type: 'assistant_message', stopReason: m.stopReason, inputTokens: m.usage?.input, outputTokens: m.usage?.output, error: m.errorMessage });
80
+ }
81
+ else if (r.type === 'message_end' && r.message?.role === 'custom') hooks.onEvent({ type: 'note', customType: r.message.customType, text: String(r.message.content ?? '').slice(0, 300) });
82
+ else if (r.type === 'tool_execution_start') hooks.onEvent({ type: 'tool_start', name: r.toolName });
83
+ else if (r.type === 'tool_execution_end') hooks.onEvent({ type: 'tool_end', name: r.toolName, isError: !!r.isError });
84
+ else if (r.type === 'tool_execution_update') hooks.onEvent({ type: 'text_delta' });
85
+ else if (r.type === 'auto_retry_start' || r.type === 'managed_provider_retry') hooks.onEvent({ type: 'provider_retry', code: r.code ?? r.errorMessage, attempt: r.attempt });
86
+ else if (r.type === 'compaction_start') hooks.onEvent({ type: 'compaction', reason: r.reason });
87
+ else if (r.type === 'managed_helpers') hooks.onEvent({ type: 'helpers', active: r.active });
88
+ else if (r.type === 'managed_turn_end') this.turn.turnEnd = r.end;
89
+ else if (r.type === 'agent_settled') this.#endTurn({});
90
+ }
91
+
92
+ async #onUi(r) {
93
+ const hooks = this.hooks;
94
+ if (r.method === 'notify') { hooks.onEvent({ type: 'notice', text: String(r.message ?? '').slice(0, 300), level: r.notifyType }); return; }
95
+ if (r.method === 'setStatus') { if (r.statusText) hooks.onEvent({ type: 'status', key: r.statusKey, text: String(r.statusText).slice(0, 200) }); return; }
96
+ if (!['select', 'confirm', 'input', 'editor'].includes(r.method)) return;
97
+ if (r.method === 'select' && r.options?.includes(SKIP)) {
98
+ const q = parseQuestion(r.title, r.options), answer = await hooks.onQuestion(q);
99
+ const other = r.options.find(o => OTHER.test(o));
100
+ const value = answer.skipped ? SKIP : answer.other != null ? other : q.raw[answer.selected[0]];
101
+ this.otherText = answer.other ?? null;
102
+ return this.#reply({ type: 'extension_ui_response', id: r.id, value });
103
+ }
104
+ if (r.method === 'input' && this.otherText != null) { const value = this.otherText; this.otherText = null; return this.#reply({ type: 'extension_ui_response', id: r.id, value }); }
105
+ if (r.method === 'confirm') return this.#reply({ type: 'extension_ui_response', id: r.id, confirmed: !!(await hooks.onApproval({ title: `${r.title} ${r.message ?? ''}` })) });
106
+ if (r.method === 'select') {
107
+ const allow = await hooks.onApproval({ title: `${r.title} [${r.options.join(' | ')}]` });
108
+ const value = r.options.find(o => (allow ? ALLOW : DENY).test(o));
109
+ return this.#reply(value ? { type: 'extension_ui_response', id: r.id, value } : { type: 'extension_ui_response', id: r.id, cancelled: true });
110
+ }
111
+ this.#reply({ type: 'extension_ui_response', id: r.id, cancelled: true });
112
+ }
113
+
114
+ #endTurn(extra) {
115
+ const turn = this.turn; if (!turn) return;
116
+ this.turn = null;
117
+ turn.resolve({ lastText: turn.lastText, turnEnd: turn.turnEnd, error: turn.error, ...extra });
118
+ }
119
+
120
+ async send(text, delivery = 'new') {
121
+ if (delivery !== 'new' && this.turn) return this.#command({ type: delivery === 'steer' ? 'steer' : 'follow_up', message: text });
122
+ let resolve; const ended = new Promise(r => { resolve = r; });
123
+ this.turn = { lastText: '', turnEnd: null, error: null, resolve, ended };
124
+ try { await this.#command({ type: 'prompt', message: text }); }
125
+ catch (error) { this.#endTurn({ error: String(error.message) }); }
126
+ }
127
+ nextTurnEnd() { return this.turn ? this.turn.ended : Promise.resolve({ lastText: '', turnEnd: null, error: 'no turn running' }); }
128
+ async stop() { if (this.child?.exitCode === null) await this.#command({ type: 'abort' }, 120_000); }
129
+
130
+ // The member closes the Terminal window mid-task and opens the same
131
+ // conversation again (/resume).
132
+ async restart() { // the Terminal closing is always hard: the conversation stops with it
133
+ if (!this.sessionFile) return false;
134
+ this.closing = true; this.child.kill('SIGKILL');
135
+ await new Promise(r => this.child.exitCode !== null || this.child.signalCode ? r() : this.child.once('exit', r));
136
+ this.#endTurn({ restarted: true });
137
+ await this.#spawn();
138
+ const switched = await this.#command({ type: 'switch_session', sessionPath: this.sessionFile }, 120_000);
139
+ return switched?.cancelled ? false : 'reopened';
140
+ }
141
+
142
+ async close() {
143
+ this.closing = true;
144
+ if (!this.child || this.child.exitCode !== null) return;
145
+ const exited = new Promise(r => this.child.once('exit', r));
146
+ this.child.stdin.end();
147
+ const timer = setTimeout(() => this.child.kill('SIGKILL'), 30_000);
148
+ await exited; clearTimeout(timer); this.stderrLog.end();
149
+ }
150
+ }
@@ -0,0 +1,214 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { WebSocket } from 'ws';
4
+ import { piagentEnv } from '../lib/workspace.mjs';
5
+
6
+ // The company WebUI (Agent Watch Auto) used the way the browser app uses it:
7
+ // the launch link's capability for a cookie and CSRF token, the Gateway
8
+ // WebSocket for commands and live events, HTTP for questions, approvals and
9
+ // the transcript. No browser: what is measured is the agent, not the page.
10
+ const opaque = prefix => `${prefix}_${randomUUID().replaceAll('-', '')}`;
11
+ const PROTOCOL = { schemaVersion: 1, version: 'piagent-gateway-protocol-v1' };
12
+ const wait = ms => new Promise(r => setTimeout(r, ms));
13
+
14
+ export class WebUiSurface {
15
+ constructor({ piagent = 'piagent' } = {}) { this.piagent = piagent; this.pending = new Map(); this.answered = new Set(); }
16
+
17
+ // `--web` prints the launch link; when no company Gateway runs yet it starts
18
+ // one and keeps serving it, so read the link without waiting for an exit
19
+ // and keep that process until the run closes.
20
+ #launch() {
21
+ return new Promise((resolve, reject) => {
22
+ const child = spawn(this.piagent, ['studio', '--web', '--no-open', '--project', this.project], { env: piagentEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
23
+ let out = '';
24
+ const timer = setTimeout(() => { child.kill(); reject(Error(`no launch link in 180 s: ${out.slice(-300)}`)); }, 180_000);
25
+ const read = d => {
26
+ out += d;
27
+ const url = out.match(/Agent Watch Auto: (http\S+)/)?.[1];
28
+ if (!url) return;
29
+ clearTimeout(timer); child.stdout.off('data', read);
30
+ if (child.exitCode === null) this.gatewayOwner ??= child;
31
+ const parsed = new URL(url), hash = new URLSearchParams(parsed.hash.slice(1));
32
+ resolve({ origin: parsed.origin, capability: hash.get('bootstrap'), projectRef: hash.get('project') });
33
+ };
34
+ child.stdout.on('data', read); child.stderr.on('data', d => { out += d; });
35
+ child.once('exit', code => { clearTimeout(timer); if (!out.includes('Agent Watch Auto: http')) reject(Error(`studio --web exited ${code}: ${out.slice(-400)}`)); });
36
+ });
37
+ }
38
+
39
+ async #login() {
40
+ // Right after another run's Gateway stopped, a launch can fail once
41
+ // (managed-launch-failed) while that Gateway is still going away.
42
+ let launched;
43
+ try { launched = await this.#launch(); } catch { await wait(15_000); launched = await this.#launch(); }
44
+ const { origin, capability, projectRef } = launched;
45
+ const response = await fetch(`${origin}/api/v1/bootstrap`, { method: 'POST', headers: { Origin: origin, 'Content-Type': 'application/json' }, body: JSON.stringify({ capability }) });
46
+ const cookie = response.headers.get('set-cookie')?.split(';', 1)[0], body = await response.json().catch(() => null);
47
+ if (!response.ok || !cookie || !body?.csrfToken) throw Error(`bootstrap failed ${response.status}`);
48
+ Object.assign(this, { origin, cookie, csrf: body.csrfToken, projectRef });
49
+ }
50
+
51
+ async http(method, path, body) {
52
+ for (let attempt = 0; ; attempt++) {
53
+ const response = await fetch(`${this.origin}${path}`, { method, headers: { Accept: 'application/json', Origin: this.origin, Cookie: this.cookie,
54
+ ...(method === 'POST' ? { 'Content-Type': 'application/json', 'X-Piagent-CSRF': this.csrf } : {}) }, body: body === undefined ? undefined : JSON.stringify(body) });
55
+ if (response.status === 401 && attempt === 0) { await this.#login(); continue; }
56
+ return { status: response.status, value: await response.json().catch(() => null) };
57
+ }
58
+ }
59
+
60
+ #connect() {
61
+ return new Promise((resolve, reject) => {
62
+ const ws = new WebSocket(`${this.origin.replace('http:', 'ws:')}/api/v1/gateway`, 'piagent.gateway.v1', { headers: { Origin: this.origin, Cookie: this.cookie }, perMessageDeflate: false });
63
+ const timer = setTimeout(() => { ws.terminate(); reject(Error('gateway hello timeout')); }, 10_000);
64
+ ws.on('open', () => ws.send(JSON.stringify({ ...PROTOCOL, messageType: 'connect', clientRef: 'soak_member', minimumProtocol: 1, maximumProtocol: 1, lastEventSequence: null, catalogRevision: null })));
65
+ ws.on('message', data => {
66
+ let frame; try { frame = JSON.parse(String(data)); } catch { return; }
67
+ if (frame.messageType === 'hello') { clearTimeout(timer); resolve(ws); return; }
68
+ if (frame.messageType === 'response') {
69
+ const p = this.pending.get(frame.requestId); if (!p) return; this.pending.delete(frame.requestId);
70
+ return frame.ok ? p.resolve(frame.result) : p.reject(Error(String(frame.error?.code ?? 'gateway-request-failed')));
71
+ }
72
+ if (frame.messageType === 'event') this.#onEvent(frame.kind, frame.payload ?? {});
73
+ });
74
+ ws.on('close', () => { if (!this.closing) this.socketLost = true; });
75
+ ws.on('error', error => { clearTimeout(timer); reject(error); });
76
+ });
77
+ }
78
+
79
+ #request(command) {
80
+ const requestId = opaque('request');
81
+ return new Promise((resolve, reject) => {
82
+ const timer = setTimeout(() => { this.pending.delete(requestId); reject(Error('gateway command timeout')); }, 60_000);
83
+ this.pending.set(requestId, { resolve: v => { clearTimeout(timer); resolve(v); }, reject: e => { clearTimeout(timer); reject(e); } });
84
+ this.ws.send(JSON.stringify({ ...PROTOCOL, messageType: 'request', requestId, method: 'sessions.command', params: { command } }));
85
+ });
86
+ }
87
+
88
+ async #command(action, payload) {
89
+ for (let attempt = 0; attempt < 4; attempt++) {
90
+ const catalog = (await this.http('GET', '/api/v1/session-catalog')).value;
91
+ const row = this.sessionRef ? catalog?.sessions?.find(s => s.sessionRef === this.sessionRef) : null;
92
+ const now = new Date();
93
+ const receipt = await this.#request({ schemaVersion: 1, version: 'piagent-session-command-v1', messageType: 'command', commandId: opaque('command'), idempotencyKey: opaque('idempotency'),
94
+ action, requestedAt: now.toISOString(), expiresAt: new Date(now.getTime() + 5 * 60_000).toISOString(), sessionRef: action === 'session.create' ? null : this.sessionRef,
95
+ expectedCatalogRevision: catalog.catalogRevision, expectedSessionRevision: action === 'session.create' ? null : row?.sessionRevision ?? null, payload });
96
+ if (receipt?.resultCode === 'stale-revision') { await wait(500); continue; }
97
+ return receipt;
98
+ }
99
+ throw Error(`${action}: stale revision four times`);
100
+ }
101
+
102
+ async open({ project, hooks }) {
103
+ Object.assign(this, { project, hooks });
104
+ await this.#login();
105
+ this.ws = await this.#connect();
106
+ const options = (await this.http('GET', '/api/v1/session-creation-options')).value;
107
+ const place = options?.projects?.find(p => p.projectRef === this.projectRef);
108
+ if (!place) throw Error('project not offered for new conversations');
109
+ this.create = { projectRef: this.projectRef, placeRef: place.placeRef, modelRef: options.defaultModelRef ?? null, thinkingLevel: options.defaultThinkingLevel ?? 'medium' };
110
+ this.poller = setInterval(() => void this.#poll().catch(() => {}), 3000);
111
+ }
112
+
113
+ #onEvent(kind, p) {
114
+ if (!this.sessionRef || p.sessionRef !== this.sessionRef) return;
115
+ const hooks = this.hooks;
116
+ if (p.operationRef) this.operationRef = p.operationRef;
117
+ if (kind === 'message.delta') hooks.onEvent({ type: 'text_delta' });
118
+ else if (kind === 'tool.started') {
119
+ if (p.toolLabel === 'compaction') hooks.onEvent({ type: 'compaction' });
120
+ else if (p.toolLabel === 'capacity-wait') hooks.onEvent({ type: 'capacity_wait' });
121
+ else hooks.onEvent({ type: 'tool_start', name: p.toolLabel });
122
+ }
123
+ else if (kind === 'tool.completed') hooks.onEvent({ type: 'tool_end', name: p.toolLabel, isError: !!p.isError, reasonCode: p.reasonCode });
124
+ else if (kind === 'message.completed') hooks.onEvent({ type: 'assistant_message' });
125
+ else if (kind === 'runtime.changed') hooks.onEvent({ type: 'status', key: 'liveState', text: `${p.liveState} ${p.reasonCode ?? ''}`.trim() });
126
+ else if (kind === 'operation.settled') void this.#settled(p);
127
+ }
128
+
129
+ async #settled(p) {
130
+ if (!this.turn) return;
131
+ const page = (await this.http('GET', `/api/v1/sessions/${encodeURIComponent(this.sessionRef)}/inspection/transcript?limit=60`)).value;
132
+ const items = page?.items ?? [];
133
+ const end = items.findLast(i => i.turnEnd)?.turnEnd ?? null;
134
+ const last = items.findLast(i => i.role === 'assistant' && i.content?.text);
135
+ const turn = this.turn; this.turn = null;
136
+ turn.resolve({ lastText: last?.content?.text ?? '', turnEnd: end, error: p.settlement === 'error' ? p.reasonCode ?? 'error' : null, settlement: p.settlement });
137
+ }
138
+
139
+ // Questions and approvals waiting for the member, as the page shows them.
140
+ async #poll() {
141
+ if (!this.sessionRef || !this.turn) return;
142
+ const base = `/api/v1/sessions/${encodeURIComponent(this.sessionRef)}`;
143
+ const asked = (await this.http('GET', `${base}/questions`)).value?.questions ?? [];
144
+ for (const entry of asked) {
145
+ if (this.answered.has(entry.questionRef)) continue;
146
+ this.answered.add(entry.questionRef);
147
+ const answers = [];
148
+ for (const q of entry.questions) {
149
+ const a = await this.hooks.onQuestion(q);
150
+ answers.push(a.skipped ? null : a.other != null ? { selected: [], other: a.other } : { selected: a.selected, other: null });
151
+ }
152
+ const body = answers.every(a => a === null) ? { skipped: true } : { answers: answers.map(a => a ?? { selected: [0], other: null }) };
153
+ await this.http('POST', `${base}/questions/${encodeURIComponent(entry.questionRef)}/answer`, body);
154
+ }
155
+ const snapshot = (await this.http('GET', `${base}/inspection/snapshot`)).value;
156
+ for (const summary of snapshot?.approvals?.pending ?? []) {
157
+ const ref = summary.approvalRef; if (!ref || this.answered.has(ref)) continue;
158
+ this.answered.add(ref);
159
+ const req = (await this.http('GET', `${base}/inspection/approvals/${encodeURIComponent(ref)}`)).value;
160
+ const allow = await this.hooks.onApproval({ title: req?.action?.summary ?? req?.title ?? ref });
161
+ await this.http('POST', `/api/v1/approvals/${encodeURIComponent(ref)}/decision`, { schemaVersion: 1, version: 'piagent-webui-approval-v1', recordType: 'decision', approvalRef: ref,
162
+ decisionId: `decision.${randomUUID()}`, decisionToken: req.decisionToken, identity: req.identity, actionDigest: req.action?.actionDigest, expectedRevisions: req.expectedRevisions,
163
+ decision: allow ? 'allow' : 'deny', reason: null, decidedAt: new Date().toISOString(), expiresAt: req.expiresAt, decisionSurface: 'webui', executor: 'pi-guard', directExecution: false });
164
+ }
165
+ }
166
+
167
+ async send(text, delivery = 'new') {
168
+ const messageRequestId = opaque('message');
169
+ if (delivery !== 'new' && this.turn && this.operationRef) {
170
+ await this.#command('session.send', { delivery: delivery === 'steer' ? 'steer' : 'follow-up', message: text, messageRequestId, expectedOperationRef: this.operationRef, attachmentRefs: [] });
171
+ return;
172
+ }
173
+ let resolve; const ended = new Promise(r => { resolve = r; });
174
+ this.turn = { resolve, ended };
175
+ const receipt = this.sessionRef
176
+ ? await this.#command('session.send', { delivery: 'new-operation', message: text, messageRequestId, expectedOperationRef: null, attachmentRefs: [] })
177
+ : await this.#command('session.create', { ...this.create, message: text, messageRequestId });
178
+ if (!receipt?.sessionRef && !this.sessionRef) { const t = this.turn; this.turn = null; t.resolve({ lastText: '', turnEnd: null, error: receipt?.error?.code ?? receipt?.resultCode ?? 'create-failed' }); return; }
179
+ this.sessionRef ??= receipt.sessionRef;
180
+ if (receipt.operationRef) this.operationRef = receipt.operationRef;
181
+ }
182
+ nextTurnEnd() { return this.turn ? this.turn.ended : Promise.resolve({ lastText: '', turnEnd: null, error: 'no turn running' }); }
183
+ async stop() { if (this.operationRef) await this.#command('session.abort', { operationRef: this.operationRef, clearQueued: true }); }
184
+
185
+ // soft: the member reloads the page (or reopens it from Agent Watch): a
186
+ // new login and socket while the conversation keeps running in the
187
+ // Gateway. hard: the Gateway itself goes away mid-task (a Piagent update,
188
+ // the dashboard closed) and is started again; the running turn is gone and
189
+ // the member writes again in the same conversation.
190
+ async restart({ hard = false } = {}) {
191
+ const owner = hard ? this.gatewayOwner : null;
192
+ this.closing = true; this.ws?.close();
193
+ if (owner && owner.exitCode === null) {
194
+ const gone = new Promise(r => owner.once('exit', r));
195
+ owner.kill('SIGTERM'); await Promise.race([gone, wait(30_000)]);
196
+ this.gatewayOwner = null;
197
+ const turn = this.turn; this.turn = null; this.operationRef = null;
198
+ turn?.resolve({ lastText: '', turnEnd: null, error: null, restarted: true });
199
+ }
200
+ this.closing = false;
201
+ await this.#login(); this.ws = await this.#connect();
202
+ return owner ? 'reopened' : 'reattached';
203
+ }
204
+
205
+ // A Gateway this run started stops with it, as when its window closes.
206
+ async close() {
207
+ this.closing = true; clearInterval(this.poller); this.ws?.close();
208
+ const owner = this.gatewayOwner;
209
+ if (!owner || owner.exitCode !== null) return;
210
+ const gone = new Promise(r => owner.once('exit', r));
211
+ owner.kill('SIGTERM');
212
+ await Promise.race([gone, wait(30_000)]);
213
+ }
214
+ }
@@ -256,8 +256,8 @@
256
256
  },
257
257
  {
258
258
  "path": "tests",
259
- "fileCount": 567,
260
- "sha256": "bdfa32dcbc3679d30a684b3307c029946a25ca9db7743af48c41bce47b1d7b5a"
259
+ "fileCount": 570,
260
+ "sha256": "49831d3854e0342c89dc2e8dc28aaa0b9a0e6ae7870e1ab5c7ab003fee12e787"
261
261
  }
262
262
  ],
263
263
  "custodianComparisonRules": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piagent/platform",
3
- "version": "1.36.1",
3
+ "version": "1.37.1",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -52,6 +52,7 @@
52
52
  "docs-site/",
53
53
  "!docs-site/assets/shots/",
54
54
  "evals/",
55
+ "!evals/long-task-soak-v1/fixtures/",
55
56
  "packs/",
56
57
  "packages/piagent-core/",
57
58
  "!packages/piagent-core/extensions/core-services.js",
@@ -15,6 +15,7 @@ import { HELPER_CALLS, HELPER_SETUP, READ_TOOLS, countedCheck } from './helper-r
15
15
  import { trackToolFailures } from './tool-failures.mjs';
16
16
  import { helperMemoryTools, renewer } from './memory.mjs';
17
17
  import { answeredSettlement, failedSettlement, settleHelper, OUTCOME_WORDS } from './helper-settlement.mjs';
18
+ import { viaLink } from './studio-link.mjs';
18
19
 
19
20
  const textContent = result => result.content?.filter(p => p.type === 'text').map(p => p.text).join('\n') ?? '';
20
21
  // A helper failure that another model of its role may not have: the model's
@@ -53,7 +54,16 @@ export async function runHelper(managed, role, task, signal, harness, { verifyGr
53
54
  }
54
55
  }
55
56
  }
57
+ // A helper's role closes through the same wait as the run's close: a passing
58
+ // failure of Agent Watch's way to Studio is asked again. A close that still
59
+ // fails leaves the helper's answer standing: the role closes with the run,
60
+ // and the next helper of the role closes it first.
61
+ export async function closeRole(managed, role) {
62
+ try { await viaLink(managed.studioLink, () => managed.broker.request('close', { role })); managed.unclosed?.delete(role); }
63
+ catch { (managed.unclosed ??= new Set()).add(role); }
64
+ }
56
65
  async function helperAttempt(managed, role, task, signal, harness, snapshot, parts, { verifyGrant, patiently, title }) {
66
+ if (managed.unclosed?.has(role)) await closeRole(managed, role);
57
67
  // A long local tool step may outlive the main lease. Refresh authority
58
68
  // before asking for a child; the broker serializes concurrent renewals,
59
69
  // and main's own searches running beside it share managed renew.
@@ -143,6 +153,6 @@ async function helperAttempt(managed, role, task, signal, harness, snapshot, par
143
153
  finally {
144
154
  for (const session of sessions) session.dispose(); await boundary?.dispose();
145
155
  await settleHelper(managed, renew.lease, report); // with the role's current token, before it closes
146
- await managed.broker.request('close', { role });
156
+ await closeRole(managed, role);
147
157
  }
148
158
  }