@tea-agent/loop-agent 0.43.0-next.14 → 0.43.0-next.16

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 (112) hide show
  1. package/CHANGELOG.md +11 -1
  2. package/dist/build-stamp.json +3 -3
  3. package/dist/executors/dag-pi-executor.js +2 -2
  4. package/dist/worker/console/chat/browser-automation.js +363 -0
  5. package/dist/worker/console/chat/browser-routes.js +36 -14
  6. package/dist/worker/console/chat/pi-runtime/custom-tools/browser-tools.js +89 -0
  7. package/dist/worker/console/chat/pi-runtime.js +9 -1
  8. package/dist/worker/console/chat/routes.js +6 -1
  9. package/dist/worker/console/chat/session-mode.js +1 -0
  10. package/dist/worker/console/chat/tools.js +4 -0
  11. package/dist/worker/console/server.js +1 -1
  12. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-1TicK2v3.js → abnfDiagram-N423BO3Z-C4NXyedy.js} +1 -1
  13. package/dist/worker/console/static/assets/{arc-BI-61Wnq.js → arc-NZ3qXvBI.js} +1 -1
  14. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-BrGzCbLC.js → architectureDiagram-T3A2C74G-COZSRvml.js} +1 -1
  15. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-C0E6CBWK.js → blockDiagram-VBNYF7ZC-Ck0Gxi9C.js} +1 -1
  16. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-D6Pnh39e.js → c4Diagram-5PPSVZJV-BX5HQ4Ox.js} +1 -1
  17. package/dist/worker/console/static/assets/channel-BxdTlb_5.js +1 -0
  18. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-DRhQr5iF.js → chunk-2GRJ4B5K-CoviemSL.js} +1 -1
  19. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-MNS7POMD.js → chunk-2Q5K7J3B-DbOdOkbZ.js} +1 -1
  20. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-DPBI_81g.js → chunk-5RXB4S5H-BsG2pmdQ.js} +1 -1
  21. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-DduCRUsX.js → chunk-5VM5RSS4-DGdvrNm8.js} +1 -1
  22. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-BQv9BtIQ.js → chunk-6Q2QTUOP-BAy7Yd6y.js} +1 -1
  23. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-CzpgRwNH.js → chunk-GF5L2VYU-D9UA7hUb.js} +1 -1
  24. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-Dv4_KVRW.js → chunk-JWPE2WC7-DPz7SZ8V.js} +1 -1
  25. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-D1qcnmuz.js → chunk-KBJHAD2P-BmuEOIh7.js} +1 -1
  26. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-CBK8NJN0.js → chunk-RYQCIY6F-CIhBWnd8.js} +1 -1
  27. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-DAIeiODy.js → chunk-XXDRQBXY-CScR6mDD.js} +1 -1
  28. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-_ByA1Nf8.js +1 -0
  29. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-_ByA1Nf8.js +1 -0
  30. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-dnp2bs3S.js → cose-bilkent-JH36ORCC-BjpfWaUr.js} +1 -1
  31. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-O1uTYY7b.js → cynefin-VYW2F7L2-BFQohJgT.js} +1 -1
  32. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-BTDfSvDI.js → cynefinDiagram-MW4NZA55-D3gzRJnS.js} +1 -1
  33. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-y_dIbZ9q.js → dagre-VZM6K2ZE-5eojkhoA.js} +1 -1
  34. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-JH8pTYhg.js → diagram-7IWD3JNH-CpD8LWu_.js} +1 -1
  35. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-nN7RP6Lm.js → diagram-B4RE2ZJO-B8_4qJ2a.js} +1 -1
  36. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-Dch4i7JV.js → diagram-LBJQPF4R-HOsXf0eu.js} +1 -1
  37. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-eKV1Psgk.js → diagram-Q27KOJAE-BqwRwiWP.js} +1 -1
  38. package/dist/worker/console/static/assets/{diagram-UB23O5K3-CQDYSqC9.js → diagram-UB23O5K3-BRVCNZe1.js} +1 -1
  39. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-Dy-884eA.js → ebnfDiagram-BXEA7PRR-Cvb1TgGh.js} +1 -1
  40. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-DLIrOwXv.js → erDiagram-JOGREHBK-DS0O0qG9.js} +1 -1
  41. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-DxkodKPO.js → flowDiagram-UKHOOZJN-BHIRvRr3.js} +1 -1
  42. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-CH_tTUOi.js → ganttDiagram-PKOTCBZU-DcCJ1JWx.js} +1 -1
  43. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-BW962oLZ.js → gitGraphDiagram-DS77QQ5N-B9ia5ony.js} +1 -1
  44. package/dist/worker/console/static/assets/{index-CHRd_RC2.css → index-B9goN3S5.css} +1 -1
  45. package/dist/worker/console/static/assets/{index-BGEQmIg4.js → index-wXlQohNs.js} +89 -88
  46. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-B7JBGEiN.js → infoDiagram-6WML65LV-3ogpgegn.js} +1 -1
  47. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-DExVPf4a.js → ishikawaDiagram-WSZJBQD7-4tgpQ5SL.js} +1 -1
  48. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-MghtTQAI.js → journeyDiagram-NVQOT4AX-DNfgHqFx.js} +1 -1
  49. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-CWDeFtqL.js → kanban-definition-27J2QSJJ-Dz7ngC8z.js} +1 -1
  50. package/dist/worker/console/static/assets/{linear-B63gdAma.js → linear-DI7Jv62E.js} +1 -1
  51. package/dist/worker/console/static/assets/{mermaid.core-BQZRRvZf.js → mermaid.core-CQA4Nzmo.js} +5 -5
  52. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-CQlHR1EK.js → mindmap-definition-FAOFIHXS-CtsMc8Mf.js} +1 -1
  53. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-DuCCddHv.js → pegDiagram-VL7TDLO6-ByppE9yH.js} +1 -1
  54. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-BxVL551q.js → pieDiagram-7S7Q4E2Y-B5PtgFgt.js} +1 -1
  55. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-DBeUEA3G.js → quadrantDiagram-CIZ2JOQS-Cq4p0k2K.js} +1 -1
  56. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-AJJHzuXc.js → railroadDiagram-AXF67PYL-DjfkyljN.js} +1 -1
  57. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-_bO2feg5.js → requirementDiagram-LRYGKXZP-DzynNRSl.js} +1 -1
  58. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-CXw0ti4x.js → sankeyDiagram-W5VNT64P-Cj7TCpTJ.js} +1 -1
  59. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-Bco8ah7u.js → sequenceDiagram-SI44F4Z6-C73C6ykA.js} +1 -1
  60. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-DypMl6A8.js → sizeCapture-X5ZJPWSS-BiNLgN2x.js} +1 -1
  61. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-C4_BnD96.js → stateDiagram-OKZ733FA-UwvjS64-.js} +1 -1
  62. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-nLgMZ0D3.js +1 -0
  63. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-DZTjbssU.js → swimlanes-SLNWSIFB-CC7DH98r.js} +2 -2
  64. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-BcYqTady.js +8 -0
  65. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-GdMEc1qP.js → timeline-definition-Z64GVDOM-LSBXTR70.js} +1 -1
  66. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-BrAoL9sU.js → vennDiagram-T6HMQDX7-BAuxWwGP.js} +1 -1
  67. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-Cg8QnF-d.js → wardleyDiagram-T6FBY63Y--E5_gjZx.js} +1 -1
  68. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-GXQPakaZ.js → xychartDiagram-ELKLHX3M-dJWNmyzF.js} +1 -1
  69. package/dist/worker/console/static/index.html +2 -2
  70. package/dist/worker/console/workspace-context.js +4 -0
  71. package/dist/worker/console/workspace-initialization.js +43 -8
  72. package/dist/workflows/dag/frontend-implementation-contract.js +4 -4
  73. package/dist/workflows/dag/frontend-verification-trace.js +7 -41
  74. package/dist/workflows/dag/init-hybrid.js +53 -8
  75. package/docs/templates/frontend-implementation-dag.json +5 -5
  76. package/package.json +1 -1
  77. package/skills/frontend-bounded-implement/SKILL.md +4 -3
  78. package/skills/frontend-bounded-implement/references/code-standards.md +1 -1
  79. package/skills/frontend-contract/references/contract-protocol.md +2 -2
  80. package/skills/frontend-design-review/SKILL.md +3 -3
  81. package/skills/frontend-design-review/references/review-checklist.md +3 -3
  82. package/skills/frontend-plan/SKILL.md +3 -3
  83. package/skills/frontend-plan/references/decision-contract.md +5 -4
  84. package/skills/frontend-review/SKILL.md +5 -5
  85. package/skills/frontend-review/references/review-findings.md +4 -4
  86. package/dist/commands/new-task.js +0 -5
  87. package/dist/commands/status.js +0 -57
  88. package/dist/commands/task-contract.js +0 -269
  89. package/dist/shared/prompts.js +0 -26
  90. package/dist/task/config.js +0 -2
  91. package/dist/task/goal.js +0 -3
  92. package/dist/task/index.js +0 -12
  93. package/dist/task/lifecycle.js +0 -1
  94. package/dist/task/operator/capabilities.js +0 -6
  95. package/dist/task/operator/envelope.js +0 -2
  96. package/dist/task/operator/index.js +0 -5
  97. package/dist/task/operator/registry.js +0 -2
  98. package/dist/task/operator/types.js +0 -1
  99. package/dist/task/paths.js +0 -1
  100. package/dist/task/source-state.js +0 -1
  101. package/dist/task/subagent-guidance.js +0 -1
  102. package/dist/worker/console/chat/sift-bridge.js +0 -1
  103. package/dist/worker/console/index.js +0 -20
  104. package/dist/worker/console/static/assets/channel-D-RWoPS8.js +0 -1
  105. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-Cb0d0EM1.js +0 -1
  106. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-Cb0d0EM1.js +0 -1
  107. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-CxDH0BMP.js +0 -1
  108. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-CkWiKV_L.js +0 -8
  109. package/dist/worker/scheduler/index.js +0 -25
  110. package/dist/workflows/dag/facts.js +0 -4
  111. package/dist/workflows/dag/index.js +0 -6
  112. package/dist/workflows/dynamic/index.js +0 -7
package/CHANGELOG.md CHANGED
@@ -2,11 +2,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ - 修复前端实现因测试名称没有 VT 编号而反复失败:按测试命令与文件关联执行结果,保留已有测试名称及测试失败阻断。
6
+
7
+ - 整理设置弹窗布局:当前工作区优先、待处理文件紧凑分组、详情按需展开,全局程序信息收拢;窄屏导航改为横向排列。
8
+
9
+ - 工作区配置按实际能力提供更新按钮,逐文件说明待处理原因与下一步,并支持复制智能体处理说明;只剩合并或确认项时不再显示无效更新按钮。
10
+
11
+ - 前端实现恢复已有测试维护与执行:优先任务声明,否则复用项目测试入口;没有测试时不强制补框架,避免只构建通过却遗漏旧单测回归。
12
+
13
+ - Operator Chat 新增对话驱动的侧边栏浏览器自动化:模型可在工作流 / Pi 会话调用 `browser_navigate` / `browser_search` / `browser_inspect` / `browser_act`,把百度等页面打开到右侧浏览器面板,执行静态搜索、GET 表单填充提交、链接点击并返回结构化页面元素;命令通道按工作区隔离,自动化不读取或修改仓库文件,也不执行页面脚本。
14
+
5
15
  - 修复前端 Plan 无法提交实现步骤的问题:finalize_plan 支持按 AC 绑定实施动作,缺漏在节点内返回修正,避免空计划反复进入设计审查。
6
16
 
7
17
  - 修复前端最终审查反复猜错范围 ID:明确 checkpoint 参数和补交步骤,收尾正确识别 checkpoint 之后的审批结论。
8
18
 
9
- - 前端实现流程简化为需求实现覆盖审查和 `npm run build`,不再强制单测或测试方案;行为测试可后补,构建结果不冒充行为验证。
19
+ - 前端实现流程简化为需求实现覆盖审查与项目验证命令,不强制独立测试方案;已有测试仍需维护并执行,无测试项目不强制补框架,构建结果不冒充行为验证。
10
20
 
11
21
  ### 改进
12
22
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.43.0-next.14",
4
- "gitSha": "31d48ba3bc6eecc584ff9fe66f78c095dcf0ca2e",
5
- "builtAt": "2026-09-11T02:21:26.141Z"
3
+ "version": "0.43.0-next.16",
4
+ "gitSha": "237591777d28ec25d8ecb561db2bd0e64380e05c",
5
+ "builtAt": "2026-09-11T07:27:48.993Z"
6
6
  }
@@ -2070,7 +2070,7 @@ export async function createFrontendPlanLedgerTools(input) {
2070
2070
  const recordPlanVerificationTargetTool = defineTool({
2071
2071
  name: "record_plan_verification_target",
2072
2072
  label: "record_plan_verification_target",
2073
- description: "Commit one plan verification target entry (origin=plan plan-verification-target fact). Reference a frozen verification command by commandId (see the frozen command directory in your prompt: static commands are project-wide checks traced by file and command only; behavior commands need a test file whose describe/it/test title contains the target id). For behavior targets, call once per distinct behavior, not mechanically once per requirement: one target may cover multiple related requirementIds. For a correction, re-submit the same id with replace=true; the ledger compiles the latest replacement. A behavior target id is the stable trace token that implementation must place in a real describe/it/test title. Entry carries id, commandId, file, requirementIds, and uiStates; optional scope (unit | component | integration) is display-only. Free-form symbol text is not accepted. IMPORTANT: batch up to 4 record_* calls per assistant message; never batch more than 4 — a larger single message risks output truncation under a small output window. Example: {\"entry\": {\"id\": \"VT-DASHBOARD-SHELL\", \"commandId\": \"<frozen behavior command id>\", \"file\": \"<test file>\", \"requirementIds\": [\"AC-001\", \"AC-002\"], \"uiStates\": []}}" +
2073
+ description: "Commit one plan verification target entry (origin=plan plan-verification-target fact). Reference a frozen verification command by commandId (see the frozen command directory in your prompt: static commands are project-wide checks traced by file and command only; behavior commands bind an existing affected test file; preserve its test names). For behavior targets, call once per distinct behavior, not mechanically once per requirement: one target may cover multiple related requirementIds. For a correction, re-submit the same id with replace=true; the ledger compiles the latest replacement. A target id identifies a contract entry only; never require it in test names. Results bind by frozen command and test file. Entry carries id, commandId, file, requirementIds, and uiStates; optional scope (unit | component | integration) is display-only. Free-form symbol text is not accepted. IMPORTANT: batch up to 4 record_* calls per assistant message; never batch more than 4 — a larger single message risks output truncation under a small output window. Example: {\"entry\": {\"id\": \"VT-DASHBOARD-SHELL\", \"commandId\": \"<frozen behavior command id>\", \"file\": \"<test file>\", \"requirementIds\": [\"AC-001\", \"AC-002\"], \"uiStates\": []}}" +
2074
2074
  (input.canonicalVerificationTargetIds &&
2075
2075
  input.canonicalVerificationTargetIds.length > 0
2076
2076
  ? ` Frozen canonical behavior target ids (use exactly for behavior targets): ${input.canonicalVerificationTargetIds.join(", ")}.`
@@ -3763,7 +3763,7 @@ const FRONTEND_PLAN_SEGMENTS = [
3763
3763
  ]),
3764
3764
  instruction: [
3765
3765
  "PLAN PHASE — requirement coverage only.",
3766
- "Your ONLY job: for every frozen requirement, emit record_plan_requirement (requirement → implementation files) and record_plan_verification_target facts (verification target bound to requirement ids and files). Group related requirements under one non-static behavior target when one observable test behavior proves them together; do not mechanically create one target per requirement. A non-static target id is the stable machine trace token; never submit prose as a symbol. Do NOT record components, UI states, mock, dependency, or routes — a follow-up session owns those.",
3766
+ "Your ONLY job: for every frozen requirement, emit record_plan_requirement (requirement → implementation files) and record_plan_verification_target facts (verification target bound to requirement ids and files). Group related requirements under one non-static behavior target when one observable test behavior proves them together; do not mechanically create one target per requirement. Target ids identify contract entries, not test-title markers. Reuse affected existing test files and their names; do not add tests or rename titles just to carry generated ids. Never submit prose as a symbol. Do NOT record components, UI states, mock, dependency, or routes — a follow-up session owns those.",
3767
3767
  "A coverage session is complete only when EVERY requirement assigned to this session (the full inventory, or the exact COVERAGE BATCH / shard list when present) has committed coverage facts: a record_plan_requirement entry plus verification targets, or a committed evidence gap. Keep committing in batches of up to 4 record_* calls per assistant message until then; do not write a concluding summary while any assigned requirement is still uncommitted — an early stop strands the remainder into a MISSING-FACT repair session and doubles the sessions needed.",
3768
3768
  "If a requirement genuinely cannot have a verification target, record a non-empty record_plan_evidence_gap. Do not call finalize_plan; it is not available in this phase.",
3769
3769
  ].join(" "),
@@ -0,0 +1,363 @@
1
+ /**
2
+ * Server-side implementation for the model-callable sidebar browser tools.
3
+ *
4
+ * Navigation is delivered to the visible BrowserPanel through a small pending
5
+ * command queue. Page inspection uses the same http(s) URL loaded by that
6
+ * panel and parses the server-visible HTML. Static GET forms are submitted as
7
+ * real navigations; dynamic scripts and POST forms are intentionally refused.
8
+ */
9
+ import { normalizeBrowserUrl } from "./browser-policy.js";
10
+ export class BrowserAutomationService {
11
+ fetchImpl;
12
+ pending = [];
13
+ sequence = 0;
14
+ constructor(fetchImpl = fetch) {
15
+ this.fetchImpl = fetchImpl;
16
+ }
17
+ async navigate(rawUrl, options) {
18
+ const normalized = normalizeBrowserUrl(rawUrl);
19
+ if (normalized.kind !== "ok") {
20
+ return {
21
+ ok: false,
22
+ error: normalized.kind === "invalid"
23
+ ? "invalid browser url"
24
+ : "browser urls must use http(s)",
25
+ };
26
+ }
27
+ let snapshot;
28
+ const snapshotSource = rewriteBaiduSearchUrlForSnapshot(normalized.url);
29
+ if (options?.includeSnapshot) {
30
+ try {
31
+ snapshot = await this.inspect(snapshotSource);
32
+ }
33
+ catch {
34
+ // Navigation itself can still succeed when the panel cannot
35
+ // fetch the page server-side; the iframe remains the fallback.
36
+ }
37
+ }
38
+ const displayUrl = snapshotSource === normalized.url ? normalized.url : snapshotSource;
39
+ const command = {
40
+ kind: "navigate",
41
+ id: this.nextId(),
42
+ url: displayUrl,
43
+ ...(snapshot ? { snapshot } : {}),
44
+ createdAt: new Date().toISOString(),
45
+ };
46
+ this.pending = [command];
47
+ return { ok: true, command };
48
+ }
49
+ reload() {
50
+ const command = {
51
+ kind: "reload",
52
+ id: this.nextId(),
53
+ createdAt: new Date().toISOString(),
54
+ };
55
+ this.pending = [command];
56
+ return { ok: true, command };
57
+ }
58
+ take() {
59
+ const commands = this.pending;
60
+ this.pending = [];
61
+ return commands;
62
+ }
63
+ async inspect(rawUrl) {
64
+ const normalized = normalizeBrowserUrl(rawUrl);
65
+ if (normalized.kind !== "ok")
66
+ throw new BrowserAutomationError("INVALID_URL", "invalid browser url");
67
+ const controller = new AbortController();
68
+ const timer = setTimeout(() => controller.abort(), 12_000);
69
+ try {
70
+ const response = await this.fetchImpl(normalized.url, {
71
+ redirect: "follow",
72
+ signal: controller.signal,
73
+ headers: { accept: "text/html,application/xhtml+xml" },
74
+ });
75
+ const contentType = response.headers.get("content-type") ?? "";
76
+ if (!contentType.includes("html")) {
77
+ throw new BrowserAutomationError("NOT_HTML", "the browser page did not return an HTML document");
78
+ }
79
+ const html = await response.text();
80
+ if (html.length > 3_000_000) {
81
+ throw new BrowserAutomationError("PAGE_TOO_LARGE", "page exceeds 3MB");
82
+ }
83
+ return parseBrowserPage(html, response.url || normalized.url);
84
+ }
85
+ catch (error) {
86
+ if (error instanceof BrowserAutomationError)
87
+ throw error;
88
+ throw new BrowserAutomationError("FETCH_FAILED", "could not load the page", {
89
+ cause: error,
90
+ });
91
+ }
92
+ finally {
93
+ clearTimeout(timer);
94
+ }
95
+ }
96
+ async search(rawQuery, rawUrl) {
97
+ const query = rawQuery.trim();
98
+ if (!query)
99
+ throw new BrowserAutomationError("EMPTY_QUERY", "search query is empty");
100
+ let target = rawUrl?.trim() ? rawUrl.trim() : "https://m.baidu.com/s?wd=";
101
+ const normalized = normalizeBrowserUrl(target);
102
+ if (normalized.kind !== "ok") {
103
+ throw new BrowserAutomationError("INVALID_URL", "search engine url must use http(s)");
104
+ }
105
+ const url = new URL(normalized.url);
106
+ url.searchParams.set(url.searchParams.has("q") ? "q" : "wd", query);
107
+ const result = await this.navigate(url.href, { includeSnapshot: true });
108
+ if (!result.ok)
109
+ throw new BrowserAutomationError("INVALID_URL", result.error);
110
+ return result.command.kind === "navigate" && result.command.snapshot
111
+ ? result.command.snapshot
112
+ : await this.inspect(url.href);
113
+ }
114
+ async act(rawUrl, request) {
115
+ const snapshot = await this.inspect(rawUrl);
116
+ const element = selectBrowserElement(snapshot, request);
117
+ if (!element) {
118
+ throw new BrowserAutomationError("ELEMENT_NOT_FOUND", "selector did not match a clickable element or form field");
119
+ }
120
+ if (request.type === "fill") {
121
+ if (element.tag !== "input" && element.tag !== "textarea") {
122
+ throw new BrowserAutomationError("NOT_FILLABLE", "matched element is not fillable");
123
+ }
124
+ const submitted = this.submitForm(snapshot, element, request.value);
125
+ const submittedSnapshot = await this.inspect(submitted.url);
126
+ const navigation = await this.navigate(submitted.url, { includeSnapshot: true });
127
+ if (!navigation.ok)
128
+ throw new BrowserAutomationError("INVALID_URL", navigation.error);
129
+ return {
130
+ ...submittedSnapshot,
131
+ action: { type: "fill", selector: request.selector, value: request.value, url: submitted.url },
132
+ };
133
+ }
134
+ const formSubmit = element.clickable && element.formIndex !== undefined && !element.href
135
+ ? this.submitForm(snapshot, element)
136
+ : undefined;
137
+ const targetUrl = formSubmit?.url ?? element.href;
138
+ if (targetUrl === undefined) {
139
+ throw new BrowserAutomationError("NOT_CLICKABLE", "matched element has no safe click target");
140
+ }
141
+ const navigation = await this.navigate(targetUrl, { includeSnapshot: true });
142
+ if (!navigation.ok)
143
+ throw new BrowserAutomationError("INVALID_URL", navigation.error);
144
+ return {
145
+ ...snapshot,
146
+ action: {
147
+ type: "click",
148
+ selector: request.selector,
149
+ url: targetUrl,
150
+ },
151
+ };
152
+ }
153
+ submitForm(snapshot, target, valueOverride) {
154
+ if (target.formIndex === undefined) {
155
+ throw new BrowserAutomationError("FORM_NOT_FOUND", "matched field is not inside a form");
156
+ }
157
+ if ((target.formMethod ?? "get") !== "get") {
158
+ throw new BrowserAutomationError("FORM_METHOD_NOT_SUPPORTED", "only GET forms can be automated without browser scripts");
159
+ }
160
+ const url = new URL(target.formAction ?? snapshot.url);
161
+ for (const element of snapshot.elements) {
162
+ if (element.formIndex !== target.formIndex || !element.name)
163
+ continue;
164
+ if (element.tag === "button")
165
+ continue;
166
+ if (element.tag === "input" && ["submit", "button", "reset", "image"].includes(element.type ?? "")) {
167
+ continue;
168
+ }
169
+ const value = element.selector === target.selector && valueOverride !== undefined
170
+ ? valueOverride
171
+ : element.value ?? "";
172
+ url.searchParams.set(element.name, value);
173
+ }
174
+ return { url: url.href };
175
+ }
176
+ nextId() {
177
+ this.sequence += 1;
178
+ return `browser-${Date.now().toString(36)}-${this.sequence}`;
179
+ }
180
+ }
181
+ export class BrowserAutomationError extends Error {
182
+ code;
183
+ constructor(code, message, options) {
184
+ super(message, options);
185
+ this.code = code;
186
+ this.name = "BrowserAutomationError";
187
+ }
188
+ }
189
+ export function rewriteBaiduSearchUrlForSnapshot(rawUrl) {
190
+ try {
191
+ const url = new URL(rawUrl);
192
+ if (url.protocol !== "https:" || url.hostname !== "www.baidu.com")
193
+ return rawUrl;
194
+ if (url.pathname.replace(/\/+$/, "") !== "/s")
195
+ return rawUrl;
196
+ const query = url.searchParams.get("wd") ?? url.searchParams.get("word");
197
+ if (query === null)
198
+ return rawUrl;
199
+ const mobile = new URL("https://m.baidu.com/s");
200
+ mobile.searchParams.set("wd", query);
201
+ return mobile.href;
202
+ }
203
+ catch {
204
+ return rawUrl;
205
+ }
206
+ }
207
+ const SKIP_TAGS = new Set(["script", "style", "template", "noscript"]);
208
+ const INTERACTIVE_TAGS = new Set(["a", "button", "input", "select", "textarea"]);
209
+ export function parseBrowserPage(html, baseUrl) {
210
+ const state = { snapshot: { url: baseUrl, title: "", elements: [] }, nextFormIndex: 0 };
211
+ const tokenizer = /<!--[\s\S]*?-->|<\/?([a-zA-Z][a-zA-Z0-9:-]*)([^>]*)>/g;
212
+ const openTags = [];
213
+ let cursor = 0;
214
+ let match;
215
+ while ((match = tokenizer.exec(html)) !== null) {
216
+ const text = decodeEntities(html.slice(cursor, match.index)).replace(/\s+/g, " ").trim();
217
+ const parent = openTags[openTags.length - 1];
218
+ if (parent && !SKIP_TAGS.has(parent.tag))
219
+ appendText(parent, text);
220
+ cursor = match.index + match[0].length;
221
+ if (match[0].startsWith("<!--"))
222
+ continue;
223
+ const tag = match[1]?.toLowerCase() ?? "";
224
+ const attrs = parseAttributes(match[2] ?? "");
225
+ if (match[0].startsWith("</")) {
226
+ const index = findOpenTag(openTags, tag);
227
+ if (index >= 0) {
228
+ const closed = openTags[index];
229
+ openTags.length = index;
230
+ closeElement(state, closed, baseUrl);
231
+ }
232
+ continue;
233
+ }
234
+ const parentForForm = openTags[openTags.length - 1];
235
+ let form = parentForForm?.form;
236
+ if (tag === "form" && !form) {
237
+ form = {
238
+ index: state.nextFormIndex,
239
+ action: attrs.action,
240
+ method: attrs.method?.toLowerCase(),
241
+ };
242
+ state.nextFormIndex += 1;
243
+ }
244
+ const element = { tag, attrs, text: "", form };
245
+ if (/\/>$/.test(match[0]) || isVoidTag(tag)) {
246
+ closeElement(state, element, baseUrl);
247
+ }
248
+ else {
249
+ openTags.push(element);
250
+ }
251
+ }
252
+ if (state.snapshot.elements.length > 300)
253
+ state.snapshot.elements.length = 300;
254
+ return state.snapshot;
255
+ }
256
+ function parseAttributes(raw) {
257
+ const attrs = {};
258
+ const pattern = /([:@a-zA-Z_#][^\s"'<>`=]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/g;
259
+ let match;
260
+ while ((match = pattern.exec(raw)) !== null) {
261
+ attrs[match[1]?.toLowerCase() ?? ""] = decodeEntities(match[2] ?? match[3] ?? match[4] ?? "");
262
+ }
263
+ return attrs;
264
+ }
265
+ function isVoidTag(tag) {
266
+ return tag === "input" || tag === "img" || tag === "br" || tag === "hr" || tag === "meta" || tag === "link";
267
+ }
268
+ function findOpenTag(stack, tag) {
269
+ for (let index = stack.length - 1; index >= 0; index -= 1) {
270
+ if (stack[index]?.tag === tag)
271
+ return index;
272
+ }
273
+ return -1;
274
+ }
275
+ function appendText(target, text) {
276
+ target.text = target.text ? `${target.text} ${text}` : text;
277
+ }
278
+ function closeElement(state, element, baseUrl) {
279
+ const { tag, attrs, text } = element;
280
+ if (tag === "title") {
281
+ state.snapshot.title = text.slice(0, 300);
282
+ return;
283
+ }
284
+ if (!INTERACTIVE_TAGS.has(tag))
285
+ return;
286
+ let href;
287
+ if (tag === "a" && attrs.href && !/^(javascript|mailto|tel):/i.test(attrs.href.trim())) {
288
+ try {
289
+ href = new URL(attrs.href, baseUrl).href;
290
+ }
291
+ catch {
292
+ href = undefined;
293
+ }
294
+ }
295
+ const id = attrs.id;
296
+ const name = attrs.name;
297
+ const selector = buildSelector(tag, id, name, attrs.class, state.snapshot.elements.length);
298
+ const rawText = text || attrs.value || attrs.placeholder || attrs["aria-label"] || attrs.title || "";
299
+ state.snapshot.elements.push({
300
+ tag,
301
+ ...(id ? { id } : {}),
302
+ text: rawText.replace(/\s+/g, " ").trim().slice(0, 240),
303
+ ...(href ? { href } : {}),
304
+ ...(name ? { name } : {}),
305
+ ...(attrs.type ? { type: attrs.type } : {}),
306
+ ...(attrs.value ? { value: attrs.value } : {}),
307
+ ...(attrs.placeholder ? { placeholder: attrs.placeholder } : {}),
308
+ ...(element.form ? { formIndex: element.form.index } : {}),
309
+ ...(element.form ? {
310
+ formAction: element.form.action === undefined
311
+ ? baseUrl
312
+ : new URL(element.form.action, baseUrl).href,
313
+ formMethod: element.form.method ?? "get",
314
+ } : {}),
315
+ selector,
316
+ clickable: tag === "a" || tag === "button" || (tag === "input" && ["submit", "button", "radio", "checkbox"].includes(attrs.type ?? "")),
317
+ });
318
+ }
319
+ function buildSelector(tag, id, name, className, index) {
320
+ if (id)
321
+ return `#${escapeSelectorValue(id)}`;
322
+ if (name)
323
+ return `${tag}[name="${escapeSelectorValue(name)}"]`;
324
+ if (className?.trim()) {
325
+ const firstClass = className.trim().split(/\s+/)[0];
326
+ if (firstClass)
327
+ return `${tag}.${escapeSelectorValue(firstClass)}`;
328
+ }
329
+ return `${tag}:nth-of-type(${index + 1})`;
330
+ }
331
+ function escapeSelectorValue(value) {
332
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
333
+ }
334
+ export function selectBrowserElement(snapshot, request) {
335
+ const selector = request.selector.trim();
336
+ if (!selector)
337
+ return undefined;
338
+ return snapshot.elements.find((element) => element.selector === selector)
339
+ ?? snapshot.elements.find((element) => element.id === selector.slice(1))
340
+ ?? snapshot.elements.find((element) => element.name === selector)
341
+ ?? snapshot.elements.find((element) => element.text.toLowerCase().includes(selector.toLowerCase()) && selector.length >= 2);
342
+ }
343
+ const ENTITY_MAP = {
344
+ amp: "&",
345
+ lt: "<",
346
+ gt: ">",
347
+ quot: "\"",
348
+ apos: "'",
349
+ nbsp: " ",
350
+ };
351
+ function decodeEntities(value) {
352
+ return value.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (whole, entity) => {
353
+ if (entity.startsWith("#x") || entity.startsWith("#X")) {
354
+ const code = Number.parseInt(entity.slice(2), 16);
355
+ return Number.isFinite(code) ? String.fromCodePoint(code) : whole;
356
+ }
357
+ if (entity.startsWith("#")) {
358
+ const code = Number.parseInt(entity.slice(1), 10);
359
+ return Number.isFinite(code) ? String.fromCodePoint(code) : whole;
360
+ }
361
+ return ENTITY_MAP[entity.toLowerCase()] ?? whole;
362
+ });
363
+ }
@@ -6,7 +6,7 @@
6
6
  * caller), restricted to http(s) URLs, with a hard timeout. Local addresses
7
7
  * are allowed — the panel routinely browses local dev servers.
8
8
  */
9
- import { embeddabilityOf, extractFrameAncestors, } from "./browser-policy.js";
9
+ import { embeddabilityOf, extractFrameAncestors, normalizeBrowserUrl, } from "./browser-policy.js";
10
10
  function workspaceIdOf(req) {
11
11
  try {
12
12
  return new URL(req.url ?? "/", "http://localhost").searchParams.get("workspaceId") ?? undefined;
@@ -52,24 +52,20 @@ export async function handleBrowserRequest(req, res, deps, pathname) {
52
52
  }
53
53
  const url = new URL(req.url ?? "/", "http://localhost");
54
54
  const raw = url.searchParams.get("url") ?? "";
55
- let parsed;
56
- try {
57
- parsed = new URL(raw);
58
- }
59
- catch {
60
- sendJson(res, 400, {
61
- ok: false,
62
- error: { code: "INVALID_URL", message: "invalid url" },
63
- });
64
- return true;
65
- }
66
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
55
+ const normalized = normalizeBrowserUrl(raw);
56
+ if (normalized.kind !== "ok") {
67
57
  sendJson(res, 400, {
68
58
  ok: false,
69
- error: { code: "BAD_SCHEME", message: "only http/https urls can be probed" },
59
+ error: {
60
+ code: normalized.kind === "blocked" ? "BAD_SCHEME" : "INVALID_URL",
61
+ message: normalized.kind === "blocked"
62
+ ? "only http/https urls can be probed"
63
+ : "invalid url",
64
+ },
70
65
  });
71
66
  return true;
72
67
  }
68
+ const parsed = new URL(normalized.url);
73
69
  const controller = new AbortController();
74
70
  const timer = setTimeout(() => controller.abort(), 8000);
75
71
  try {
@@ -116,6 +112,32 @@ export async function handleBrowserRequest(req, res, deps, pathname) {
116
112
  clearTimeout(timer);
117
113
  }
118
114
  }
115
+ if (method === "GET" && sub === "commands" && parts.length === 1) {
116
+ const ws = workspaceIdOf(req);
117
+ if (!ws) {
118
+ sendJson(res, 400, {
119
+ ok: false,
120
+ error: { code: "WORKSPACE_REQUIRED", message: "workspaceId is required" },
121
+ });
122
+ return true;
123
+ }
124
+ if (deps.requireWorkspace) {
125
+ const scope = deps.requireWorkspace(req);
126
+ if (!scope.ok) {
127
+ sendJson(res, 403, {
128
+ ok: false,
129
+ error: { code: "WORKSPACE_DENIED", message: "workspace scope mismatch" },
130
+ });
131
+ return true;
132
+ }
133
+ }
134
+ if (!deps.automation) {
135
+ sendJson(res, 200, { ok: true, data: { commands: [] } });
136
+ return true;
137
+ }
138
+ sendJson(res, 200, { ok: true, data: { commands: deps.automation.take() } });
139
+ return true;
140
+ }
119
141
  sendJson(res, 404, {
120
142
  ok: false,
121
143
  error: { code: "NOT_FOUND", message: `unknown browser route: ${method} ${pathname}` },
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Session-independent browser tools shared by workflow and Pi chat modes.
3
+ * Navigation is visible in the Console sidebar; inspection is server-side and
4
+ * bounded to avoid exposing raw credentials or arbitrary page scripts.
5
+ */
6
+ const BROWSER_TOOLS_DESCRIPTION = "Operate the Console sidebar browser. `browser_navigate` shows a URL in the panel; `browser_search` opens a search URL and returns elements; `browser_inspect` returns title/links/form controls; `browser_act` fills/submits a static GET form or clicks a static link. For natural-language search requests, prefer `browser_search` over navigating to a homepage and simulating its form. It never edits the workspace and cannot execute browser scripts. Dynamic-page and POST-form operations are out of scope.";
7
+ export async function buildBrowserTools(options) {
8
+ const [{ Type }, { defineTool }] = await Promise.all([
9
+ import("typebox"),
10
+ import("@earendil-works/pi-coding-agent"),
11
+ ]);
12
+ const automation = options.automation;
13
+ const urlParameter = Type.String({
14
+ description: "Absolute http(s) URL. Prefer URLs/element selectors returned by prior browser tools.",
15
+ });
16
+ async function execute(operation) {
17
+ try {
18
+ const result = await operation();
19
+ return {
20
+ content: [{ type: "text", text: JSON.stringify(result) }],
21
+ details: result,
22
+ };
23
+ }
24
+ catch (error) {
25
+ const payload = {
26
+ error: error instanceof Error ? error.message : String(error),
27
+ code: error instanceof Error && "code" in error
28
+ ? String(error.code)
29
+ : "BROWSER_OPERATION_FAILED",
30
+ };
31
+ return {
32
+ content: [{ type: "text", text: JSON.stringify(payload) }],
33
+ details: payload,
34
+ };
35
+ }
36
+ }
37
+ return [
38
+ defineTool({
39
+ name: "browser_navigate",
40
+ label: "Sidebar browser navigate",
41
+ description: BROWSER_TOOLS_DESCRIPTION,
42
+ promptSnippet: BROWSER_TOOLS_DESCRIPTION,
43
+ parameters: Type.Object({ url: urlParameter }, { additionalProperties: false }),
44
+ async execute(_toolCallId, params) {
45
+ return execute(() => automation.navigate(params.url, { includeSnapshot: true }));
46
+ },
47
+ }),
48
+ defineTool({
49
+ name: "browser_search",
50
+ label: "Sidebar browser search",
51
+ description: BROWSER_TOOLS_DESCRIPTION,
52
+ promptSnippet: BROWSER_TOOLS_DESCRIPTION,
53
+ parameters: Type.Object({
54
+ query: Type.String({ description: "Search query." }),
55
+ engineUrl: Type.Optional(urlParameter),
56
+ }, { additionalProperties: false }),
57
+ async execute(_toolCallId, params) {
58
+ return execute(() => automation.search(params.query, params.engineUrl));
59
+ },
60
+ }),
61
+ defineTool({
62
+ name: "browser_inspect",
63
+ label: "Sidebar browser inspect",
64
+ description: BROWSER_TOOLS_DESCRIPTION,
65
+ promptSnippet: BROWSER_TOOLS_DESCRIPTION,
66
+ parameters: Type.Object({ url: urlParameter }, { additionalProperties: false }),
67
+ async execute(_toolCallId, params) {
68
+ return execute(() => automation.inspect(params.url));
69
+ },
70
+ }),
71
+ defineTool({
72
+ name: "browser_act",
73
+ label: "Sidebar browser act",
74
+ description: BROWSER_TOOLS_DESCRIPTION,
75
+ promptSnippet: BROWSER_TOOLS_DESCRIPTION,
76
+ parameters: Type.Object({
77
+ url: urlParameter,
78
+ type: Type.Union([Type.Literal("click"), Type.Literal("fill")]),
79
+ selector: Type.String({ description: "Selector returned by browser_inspect." }),
80
+ value: Type.Optional(Type.String()),
81
+ }, { additionalProperties: false }),
82
+ async execute(_toolCallId, params) {
83
+ return execute(() => automation.act(params.url, params.type === "click"
84
+ ? { type: "click", selector: params.selector }
85
+ : { type: "fill", selector: params.selector, value: params.value ?? "" }));
86
+ },
87
+ }),
88
+ ];
89
+ }
@@ -43,12 +43,14 @@ import { incrementCounter as incrementPressureCounter } from "../../../shared/pi
43
43
  import { resolveDefaultChatModel, resolveLowChatModel, } from "./model-resolver.js";
44
44
  import { filterActiveInterviewTools } from "../interview/tools.js";
45
45
  import { redactRuntimeText } from "./runtime-context.js";
46
+ import { BrowserAutomationService } from "./browser-automation.js";
46
47
  import { buildTodoContinuationContext, } from "./todos.js";
47
48
  import { buildPersistentTerminalTools } from "./terminal-tools.js";
48
49
  import { buildScmTools } from "./scm-tools.js";
49
50
  import { buildGoalContinuationContext, } from "./goals.js";
50
51
  import { injectWizardLocalHeaders, } from "./sdd-data-alignment.js";
51
52
  import { buildCustomOperatorTools } from "./pi-runtime/custom-tools/operator-tools.js";
53
+ import { buildBrowserTools } from "./pi-runtime/custom-tools/browser-tools.js";
52
54
  import { buildAskUserQuestionTool } from "./pi-runtime/custom-tools/ask-user-question.js";
53
55
  import { buildTodoWriteTool } from "./pi-runtime/custom-tools/todo-write.js";
54
56
  import { buildGoalTools } from "./pi-runtime/custom-tools/goal-tools.js";
@@ -78,7 +80,7 @@ import { createDefaultPiSdkBindings, safeGetAgentDir, } from "./pi-runtime/sdk-b
78
80
  */
79
81
  export const OPERATOR_CHAT_SYSTEM_PROMPT_BASE = [
80
82
  "You are General Operator Chat.",
81
- "Use operator tools first (dagReport, dagRerunPlan, dagRerun). Pi read/write/edit/bash/grep/find/ls are available; obey repository AGENTS.md. apply_patch, full-tools, shell and coding-chat are denied. Prefer safe-read/safe-grep for probes.",
83
+ "Use operator tools first (dagReport, dagRerunPlan, dagRerun). Pi read/write/edit/bash/grep/find/ls are available; obey repository AGENTS.md. apply_patch, full-tools, shell and coding-chat are denied. Prefer safe-read/safe-grep for probes. Browser_* drives the visible Browser panel only (static http(s), no workspace edits/scripts).",
82
84
  "No harness and asked to init: `loop-agent init instructions --repo-root .`, then `loop-agent init --repo-root . --profile full --merge`; verify with doctor, inspect and docs audit. Updates: `loop-agent init upgrade --repo-root . --json` until stable; `loop-agent init check-update` is read-only.",
83
85
  "Long DAGs: prepareDagExecution → runDag → operationId. Never foreground-Bash task advance, tail/head pipes or nohup/Start-Process/start; do not end on accepted/queued/running or an operationId — NOT completion. Supervise operationGet/status/dagReport/dagDoctor/operationWait at 60s → 90s → 120s → 180s; reset to 60s on state change; 30-60s re-checks when stall suspected. operationWait MUST use afterSeq=previous nextSeq (recommendedNextCall). Heartbeats: not meaningful progress. Run id ≠ task id.",
84
86
  "Failure: terminal runSummary first. With progress wait; else dagRerunPlan(fromNode=primaryFailure.nodeId); eligible → dagRerun: reruns the failed subgraph, keeps prior successes. Ineligible: Worker → workerTaskRetry; standalone → standaloneTaskRerun after contract adopt (regenerates the whole task, not node continue). Do not call dagResume; do not re-advance a run-failed task. Pre-run identical state twice: stop and report; never loop identical bash/read/grep. Approve only bounded writeSet inside allowedPaths, outside forbiddenPaths, with structured verification.",
@@ -415,10 +417,12 @@ export class ConsolePiRuntime {
415
417
  exploreCustomToolsPromise;
416
418
  exploreOrchestrator;
417
419
  bindings;
420
+ browserAutomation;
418
421
  constructor(options) {
419
422
  this.options = options;
420
423
  this.loader = createOperatorChatResourceLoader();
421
424
  this.bindings = options.bindings ?? createDefaultPiSdkBindings();
425
+ this.browserAutomation = options.browserAutomation ?? new BrowserAutomationService();
422
426
  this.exploreOrchestrator = new ExploreOrchestrator({
423
427
  cwd: this.options.cwd,
424
428
  bindings: this.bindings,
@@ -972,6 +976,9 @@ export class ConsolePiRuntime {
972
976
  : Promise.resolve([]),
973
977
  this.loadExploreCustomTools(),
974
978
  ]);
979
+ const browserTools = await buildBrowserTools({
980
+ automation: this.browserAutomation,
981
+ });
975
982
  const questionTool = this.options.questionRegistry
976
983
  ? await buildAskUserQuestionTool({
977
984
  sessionId,
@@ -1030,6 +1037,7 @@ export class ConsolePiRuntime {
1030
1037
  ...goalTools,
1031
1038
  ...terminalTools,
1032
1039
  ...scmTools,
1040
+ ...browserTools,
1033
1041
  ...(agentTool ? [agentTool] : []),
1034
1042
  ];
1035
1043
  }
@@ -4280,7 +4280,12 @@ async function dispatchChatRequest(req, res, deps, pathname, workspaceScope) {
4280
4280
  }
4281
4281
  }
4282
4282
  // Browser probe API (read-only; explains iframe refusals to the panel).
4283
- if (await handleBrowserRequest(req, res, {}, pathname)) {
4283
+ if (await handleBrowserRequest(req, res, {
4284
+ automation: deps.browserAutomation,
4285
+ requireWorkspace: () => workspaceScope
4286
+ ? { ok: true, workspaceId: workspaceScope }
4287
+ : { ok: false },
4288
+ }, pathname)) {
4284
4289
  return true;
4285
4290
  }
4286
4291
  if (!pathname.startsWith("/api/operator/v1/chat/") &&