@moretea-labs/forge 1.6.0 → 1.7.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.
Files changed (396) hide show
  1. package/AGENTS.md +2 -0
  2. package/CHANGELOG.md +26 -0
  3. package/CLAUDE.md +2 -0
  4. package/README.md +2 -2
  5. package/README.zh-CN.md +1 -1
  6. package/SKILL.md +2 -2
  7. package/THIRD_PARTY_NOTICES.md +1 -1
  8. package/agents/openai.yaml +1 -1
  9. package/assets/hooks/hook-input.sh +11 -8
  10. package/assets/hooks/lib/session-state.sh +10 -7
  11. package/assets/hooks/lib/workflow-state.sh +13 -177
  12. package/assets/hooks/post-tool-observer.sh +8 -2
  13. package/assets/hooks/prompt-guard.sh +5 -81
  14. package/assets/hooks/session-start-context.sh +56 -1
  15. package/assets/partials/05-workflow.partial.md +1 -1
  16. package/assets/partials-agents/03-orchestration.partial.md +1 -1
  17. package/assets/plugin-registry.v1.json +1 -1
  18. package/assets/reference-configs/document-generation.md +1 -1
  19. package/assets/reference-configs/harness-overview.md +1 -1
  20. package/assets/reference-configs/sprint-contracts.md +3 -3
  21. package/assets/skill-version.json +2 -2
  22. package/assets/templates/contract.template.md +2 -7
  23. package/assets/templates/helpers/capture-plan.sh +2 -2
  24. package/assets/templates/helpers/check-task-workflow.sh +4 -4
  25. package/assets/templates/helpers/contract-worktree.sh +1 -21
  26. package/assets/templates/helpers/ensure-task-workflow.sh +11 -36
  27. package/assets/templates/helpers/migrate-project-template.sh +22 -45
  28. package/assets/templates/helpers/new-plan.sh +1 -1
  29. package/assets/templates/helpers/plan-to-todo.sh +12 -37
  30. package/assets/templates/helpers/ship-worktrees.sh +2 -29
  31. package/assets/templates/helpers/verify-contract.sh +1 -111
  32. package/assets/templates/helpers/verify-sprint.sh +3 -180
  33. package/assets/templates/plan.template.md +1 -1
  34. package/assets/templates/review.template.md +9 -34
  35. package/assets/workflow-contract.v1.json +10 -19
  36. package/docs/README.md +10 -10
  37. package/docs/architecture/CURRENT.md +170 -0
  38. package/docs/architecture/EVOLUTION.md +42 -0
  39. package/docs/architecture/current/README.md +4 -156
  40. package/docs/architecture/decisions/20260803-release-identity-binding-and-exit-policy.md +2 -2
  41. package/docs/architecture/decisions/20260823-immediate-controller-relay.md +53 -0
  42. package/docs/architecture/decisions/20260826-browser-runtime-v2.md +73 -0
  43. package/docs/architecture/history/README.md +7 -0
  44. package/docs/architecture/{chatgpt-handoff-facade.md → history/chatgpt-handoff-facade.md} +4 -0
  45. package/docs/architecture/{global-hook-runtime.md → history/global-hook-runtime.md} +7 -3
  46. package/docs/architecture/{ios-semantic-automation-provider-v2.md → history/ios-semantic-automation-provider-v2.md} +6 -2
  47. package/docs/architecture/history.md +3 -34
  48. package/docs/architecture/index.md +15 -98
  49. package/docs/architecture/modules/controller-runtime/transport-lifecycle.md +1 -1
  50. package/docs/architecture/reviews/2026-08-22-semantic-harness-convergence-acceptance.md +39 -0
  51. package/docs/architecture/versions/1.6.md +38 -0
  52. package/docs/architecture/versions/1.7.md +41 -0
  53. package/docs/forge-chatgpt-browser-engine.md +2 -2
  54. package/docs/forge-chatgpt-mcp-setup.md +4 -28
  55. package/docs/forge-chatgpt-supervised-automation.md +3 -3
  56. package/docs/forge-github-issue-launcher.md +1 -1
  57. package/docs/forge-runtime-self-healing-loop.md +1 -1
  58. package/docs/operations/controller-browser-plugin.md +26 -6
  59. package/docs/operations/controller-performance-and-502.md +124 -0
  60. package/docs/operations/controller-reliability-runbook.md +1 -1
  61. package/docs/operations/features.md +2 -2
  62. package/docs/operations/github-repository.md +1 -1
  63. package/docs/operations/homebrew.md +2 -2
  64. package/docs/operations/mcp-tool-exposure.md +5 -3
  65. package/docs/wiki/Architecture.md +6 -6
  66. package/docs/wiki/Implementation.md +2 -2
  67. package/docs/wiki/Integrations.md +2 -2
  68. package/docs/wiki/Work-Lifecycle.md +1 -1
  69. package/package.json +29 -10
  70. package/scripts/activate-source-baseline.ts +17 -13
  71. package/scripts/benchmark-background-check-overlap.ts +228 -0
  72. package/scripts/benchmark-mcp-host.sh +117 -0
  73. package/scripts/benchmark-mcp-transport.ts +170 -0
  74. package/scripts/benchmark-quality-harness.ts +146 -0
  75. package/scripts/benchmark-route-session-concurrency.ts +207 -6
  76. package/scripts/benchmark-thin-harness-mcp-e2e.ts +7 -1
  77. package/scripts/benchmark-typescript-navigation.ts +186 -0
  78. package/scripts/benchmark-work-efficiency.ts +334 -0
  79. package/scripts/capture-plan.sh +3 -3
  80. package/scripts/check-agent-tooling.sh +8 -35
  81. package/scripts/check-architecture-sync.sh +17 -86
  82. package/scripts/check-mcp-compatibility.ts +14 -0
  83. package/scripts/check-runtime-architecture.mjs +107 -64
  84. package/scripts/check-stable-baseline.ts +92 -0
  85. package/scripts/check-task-workflow.sh +4 -4
  86. package/scripts/contract-worktree.sh +1 -21
  87. package/scripts/ensure-task-workflow.sh +22 -55
  88. package/scripts/host-temporary-cloud-mcp-e2e.sh +265 -0
  89. package/scripts/install-standalone-recovery.ts +24 -0
  90. package/scripts/lib/project-init-lib.sh +15 -44
  91. package/scripts/migrate-project-template.sh +23 -1179
  92. package/scripts/new-plan.sh +1 -1
  93. package/scripts/plan-to-todo.sh +11 -36
  94. package/scripts/run-governed-gate.ts +2 -1
  95. package/scripts/ship-worktrees.sh +2 -29
  96. package/scripts/smoke-ios-physical-device.ts +4 -2
  97. package/scripts/smoke-runtime-recovery.ts +1 -35
  98. package/scripts/smoke-schedule-engine.ts +1 -20
  99. package/scripts/verify-contract.sh +1 -111
  100. package/scripts/verify-forge-runtime.sh +0 -1
  101. package/scripts/verify-sprint.sh +3 -180
  102. package/scripts/workflow-contract.ts +1 -5
  103. package/src/cli/commands/init-hook.ts +3 -3
  104. package/src/cli/commands/mcp.ts +9 -5
  105. package/src/cli/commands/plugin.ts +1 -1
  106. package/src/cli/commands/recovery.ts +25 -3
  107. package/src/cli/commands/runtime.ts +55 -3
  108. package/src/cli/commands/security.ts +12 -1
  109. package/src/cli/commands/setup-profile.ts +31 -21
  110. package/src/cli/controller/check-runner.ts +106 -3
  111. package/src/cli/controller/composite-result.ts +1 -1
  112. package/src/cli/controller/context/focus.ts +51 -0
  113. package/src/cli/controller/context/instruction-context.ts +114 -0
  114. package/src/cli/controller/context/known-paths.ts +76 -0
  115. package/src/cli/controller/context/query-planning.ts +110 -0
  116. package/src/cli/controller/context/rule-resolution.ts +532 -0
  117. package/src/cli/controller/context/source-materializer.ts +278 -0
  118. package/src/cli/controller/context/structural.ts +16 -0
  119. package/src/cli/controller/context/types.ts +286 -0
  120. package/src/cli/controller/context-pack.ts +785 -469
  121. package/src/cli/controller/launch-agents.ts +77 -2
  122. package/src/cli/controller/operational-plan.ts +2 -2
  123. package/src/cli/controller/work-mode.ts +6 -4
  124. package/src/cli/editing/edit-session.ts +60 -7
  125. package/src/cli/github/github.ts +133 -0
  126. package/src/cli/installer/managed-entries.ts +15 -1
  127. package/src/cli/local-bridge/console-automations.ts +54 -78
  128. package/src/cli/local-bridge/console-view-models.ts +1 -35
  129. package/src/cli/local-bridge/facade-api.ts +0 -217
  130. package/src/cli/local-bridge/frontend/src/App.tsx +3 -3
  131. package/src/cli/local-bridge/frontend/src/features/automations/AutomationsView.tsx +23 -13
  132. package/src/cli/local-bridge/frontend/src/features/capabilities/CapabilitiesView.tsx +37 -4
  133. package/src/cli/local-bridge/frontend/src/features/overview/OverviewView.tsx +6 -4
  134. package/src/cli/local-bridge/frontend/src/lib/api.ts +1 -6
  135. package/src/cli/local-bridge/frontend/src/lib/routes.ts +1 -1
  136. package/src/cli/local-bridge/frontend/src/shell/Sidebar.tsx +2 -2
  137. package/src/cli/local-bridge/frontend/src/styles.css +1 -1
  138. package/src/cli/local-bridge/frontend/src/types.ts +5 -8
  139. package/src/cli/local-bridge/job-store.ts +1 -490
  140. package/src/cli/local-bridge/server.ts +0 -513
  141. package/src/cli/local-bridge/ui-dist/app.css +1 -1
  142. package/src/cli/local-bridge/ui-dist/app.js +3703 -3757
  143. package/src/cli/mcp/auth.ts +5 -5
  144. package/src/cli/mcp/instructions.ts +3 -2
  145. package/src/cli/mcp/legacy-tool-service.ts +0 -189
  146. package/src/cli/mcp/multi-repository.ts +16 -5
  147. package/src/cli/mcp/oauth.ts +175 -26
  148. package/src/cli/mcp/repository-tools.ts +247 -107
  149. package/src/cli/mcp/server.ts +537 -111
  150. package/src/cli/mcp/tool-capability-cache.ts +1 -1
  151. package/src/cli/mcp/toolset-selection.ts +1 -1
  152. package/src/cli/mcp/toolset.ts +42 -5
  153. package/src/cli/mcp/transports/http.ts +101 -14
  154. package/src/cli/mcp/transports/session-registry.ts +43 -5
  155. package/src/cli/mcp/types.ts +3 -3
  156. package/src/cli/repositories/command-classifier.ts +101 -19
  157. package/src/cli/repositories/command-executor.ts +55 -521
  158. package/src/cli/repositories/command-process.ts +108 -0
  159. package/src/cli/repositories/command-scope.ts +99 -41
  160. package/src/cli/repositories/controller-home.ts +7 -8
  161. package/src/cli/repositories/git-command-executor.ts +2 -0
  162. package/src/cli/repositories/locks.ts +53 -16
  163. package/src/cli/repositories/repository-snapshot.ts +121 -0
  164. package/src/cli/repositories/selected-path-actions.ts +17 -9
  165. package/src/cli/repositories/structured-git.ts +61 -3
  166. package/src/cli/repository/inspector.ts +553 -63
  167. package/src/cli/repository/session-cache.ts +58 -17
  168. package/src/cli/v81-entry.ts +1 -4
  169. package/src/effects/fs-transaction.ts +85 -17
  170. package/src/runtime/context/codegraph-read-provider.ts +233 -2
  171. package/src/runtime/context/codegraph-sidecar.cjs +74 -40
  172. package/src/runtime/context/generic-lsp-provider.ts +355 -0
  173. package/src/runtime/context/lsp-client.ts +283 -0
  174. package/src/runtime/context/semantic-navigation.ts +275 -0
  175. package/src/runtime/context/swift-navigation.ts +565 -0
  176. package/src/runtime/context/typescript-navigation.ts +226 -0
  177. package/src/runtime/control-plane/dispatch-priority.ts +28 -0
  178. package/src/runtime/control-plane/execution/direct-edit-work-completion.ts +47 -8
  179. package/src/runtime/control-plane/execution/edit-validation-coordinator.ts +86 -24
  180. package/src/runtime/control-plane/execution/session-store.ts +18 -0
  181. package/src/runtime/control-plane/execution/verification-evidence.ts +58 -3
  182. package/src/runtime/control-plane/execution/work-handle-store.ts +27 -2
  183. package/src/runtime/control-plane/execution/work-lifecycle-audit.ts +258 -0
  184. package/src/runtime/control-plane/execution/work-remote-delivery.ts +215 -0
  185. package/src/runtime/control-plane/execution/work-task-receipt.ts +54 -11
  186. package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +143 -8
  187. package/src/runtime/control-plane/execution/work-validation-reconciler.ts +22 -14
  188. package/src/runtime/control-plane/execution/work-verification-snapshot.ts +85 -8
  189. package/src/runtime/control-plane/facade/capability-registry.ts +129 -25
  190. package/src/runtime/control-plane/facade/controller-round-relay.ts +991 -0
  191. package/src/runtime/control-plane/facade/controller-session-store.ts +132 -1
  192. package/src/runtime/control-plane/facade/goal-workloop-access.ts +2 -0
  193. package/src/runtime/control-plane/facade/goal-workloop.ts +686 -129
  194. package/src/runtime/control-plane/facade/index.ts +1 -0
  195. package/src/runtime/control-plane/facade/plan-contract-store.ts +185 -20
  196. package/src/runtime/control-plane/facade/semantic-admission.ts +205 -0
  197. package/src/runtime/control-plane/facade/suggested-actions.ts +5 -1
  198. package/src/runtime/control-plane/facade/types.ts +22 -5
  199. package/src/runtime/control-plane/facade/work-contract-store.ts +175 -73
  200. package/src/runtime/control-plane/generated-cache-retention.ts +271 -0
  201. package/src/runtime/control-plane/global-scheduler/config.ts +44 -0
  202. package/src/runtime/control-plane/global-scheduler/dispatch-capacity.ts +80 -0
  203. package/src/runtime/control-plane/global-scheduler/durable-admission.ts +46 -0
  204. package/src/runtime/control-plane/global-scheduler/maintenance.ts +145 -0
  205. package/src/runtime/control-plane/global-scheduler/projection-refresh.ts +84 -0
  206. package/src/runtime/control-plane/global-scheduler/reconciliation.ts +3 -3
  207. package/src/runtime/control-plane/global-scheduler/scheduler.ts +273 -542
  208. package/src/runtime/control-plane/global-scheduler/source-scan.ts +59 -0
  209. package/src/runtime/control-plane/global-scheduler/state.ts +88 -0
  210. package/src/runtime/control-plane/global-scheduler/worker-attachment.ts +57 -0
  211. package/src/runtime/control-plane/global-scheduler/worker-exit-decision.ts +38 -0
  212. package/src/runtime/control-plane/global-scheduler/worker-exit-reconciler.ts +175 -0
  213. package/src/runtime/control-plane/global-scheduler/worker-launch.ts +111 -0
  214. package/src/runtime/control-plane/global-scheduler/worker-lifecycle-store.ts +64 -0
  215. package/src/runtime/control-plane/global-scheduler/worker-lifecycle.ts +150 -0
  216. package/src/runtime/control-plane/global-scheduler/worker-process.ts +119 -0
  217. package/src/runtime/control-plane/global-scheduler/worker-stderr.ts +60 -0
  218. package/src/runtime/control-plane/launcher/chatgpt-work-binding-store.ts +50 -0
  219. package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +355 -116
  220. package/src/runtime/control-plane/launcher/launch-reservation-store.ts +40 -0
  221. package/src/runtime/control-plane/launcher/thin-launcher.ts +194 -10
  222. package/src/runtime/control-plane/persistence/controller-home-migration.ts +487 -0
  223. package/src/runtime/control-plane/persistence/sqlite-store.ts +33 -2
  224. package/src/runtime/control-plane/release-retention.ts +62 -2
  225. package/src/runtime/control-plane/routing/route-policy.ts +7 -16
  226. package/src/runtime/control-plane/runtime-status-client.ts +0 -2
  227. package/src/runtime/diagnostics/mcp-timing.ts +13 -0
  228. package/src/runtime/diagnostics/mcp-transport-benchmark.ts +129 -0
  229. package/src/runtime/diagnostics/performance.ts +24 -1
  230. package/src/runtime/evidence/event-ledger.ts +1 -1
  231. package/src/runtime/execution/jobs/store.ts +58 -4
  232. package/src/runtime/execution/jobs/types.ts +1 -1
  233. package/src/runtime/execution/managed-workspace.ts +203 -1
  234. package/src/runtime/execution/process-runtime/check-facade.ts +51 -4
  235. package/src/runtime/execution/process-runtime/check-result.ts +53 -0
  236. package/src/runtime/execution/process-runtime/check-scheduling.ts +114 -0
  237. package/src/runtime/execution/process-runtime/command-facade.ts +345 -40
  238. package/src/runtime/execution/process-runtime/gc.ts +38 -6
  239. package/src/runtime/execution/process-runtime/lightweight-managed.ts +1184 -0
  240. package/src/runtime/execution/process-runtime/persisted-check.ts +47 -4
  241. package/src/runtime/execution/process-runtime/resource-claims.ts +10 -6
  242. package/src/runtime/execution/process-runtime/runtime.ts +43 -3
  243. package/src/runtime/execution/process-runtime/store.ts +1 -0
  244. package/src/runtime/execution/process-runtime/types.ts +2 -0
  245. package/src/runtime/execution/thin-harness/async-process.ts +12 -2
  246. package/src/runtime/execution/thin-harness/search-worker.ts +54 -5
  247. package/src/runtime/execution/workers/executor.ts +0 -48
  248. package/src/runtime/gateway/mcp/execution-tools.ts +1192 -114
  249. package/src/runtime/gateway/mcp/process-tools.ts +83 -37
  250. package/src/runtime/gateway/mcp/router.ts +184 -82
  251. package/src/runtime/gateway/mcp/runtime-tool-definitions.ts +546 -0
  252. package/src/runtime/gateway/mcp/runtime-tools.ts +2224 -1589
  253. package/src/runtime/plugins/app-store-connect-adapter.ts +123 -19
  254. package/src/runtime/plugins/browser-adapter.ts +2901 -241
  255. package/src/runtime/plugins/browser-automation-service.ts +10 -0
  256. package/src/runtime/plugins/browser-handoff.ts +92 -2
  257. package/src/runtime/plugins/browser-macos-bridge.ts +582 -49
  258. package/src/runtime/plugins/browser-node-bridge.ts +6 -34
  259. package/src/runtime/plugins/browser-provider-registry.ts +109 -0
  260. package/src/runtime/plugins/browser-runtime-contract.ts +152 -0
  261. package/src/runtime/plugins/browser-runtime.ts +183 -0
  262. package/src/runtime/plugins/browser-session-authority.ts +332 -0
  263. package/src/runtime/plugins/capability-authorization-grants.ts +354 -0
  264. package/src/runtime/plugins/desktop-operator-registration.ts +8 -2
  265. package/src/runtime/plugins/external-adapter.ts +324 -3
  266. package/src/runtime/plugins/external-unix-socket.ts +7 -1
  267. package/src/runtime/plugins/first-party-registry.ts +2 -0
  268. package/src/runtime/plugins/github-adapter.ts +125 -0
  269. package/src/runtime/plugins/ios/agent-device-provider.ts +1 -1
  270. package/src/runtime/plugins/ios/agent-device-typed-provider.ts +52 -9
  271. package/src/runtime/plugins/ios/remote-xpc-hid.ts +1171 -0
  272. package/src/runtime/plugins/ios-adapter.ts +106 -16
  273. package/src/runtime/plugins/ios-agent-device.ts +234 -64
  274. package/src/runtime/plugins/ios-physical-device.ts +1143 -392
  275. package/src/runtime/plugins/lightweight-action.ts +153 -0
  276. package/src/runtime/plugins/local-system-adapter.ts +123 -6
  277. package/src/runtime/plugins/macos-capability-broker.ts +55 -9
  278. package/src/runtime/plugins/plugin-action-sidecar.ts +59 -0
  279. package/src/runtime/plugins/plugin-management-adapter.ts +49 -8
  280. package/src/runtime/plugins/resend-adapter.ts +1 -1
  281. package/src/runtime/plugins/store.ts +269 -29
  282. package/src/runtime/plugins/types.ts +35 -2
  283. package/src/runtime/plugins/xiaohongshu-publish.ts +515 -0
  284. package/src/runtime/projections/git-status-sampler.ts +16 -16
  285. package/src/runtime/projections/materialized-view.ts +28 -10
  286. package/src/runtime/recovery/maintenance-executor.ts +393 -28
  287. package/src/runtime/root/mcp-transport.ts +180 -10
  288. package/src/runtime/root/package-connector-service.ts +186 -8
  289. package/src/runtime/root/package-runtime-release.ts +143 -17
  290. package/src/runtime/root/package-runtime-service.ts +430 -22
  291. package/src/runtime/root/release-manifest.ts +7 -2
  292. package/src/runtime/root/release-materialize.ts +19 -0
  293. package/src/runtime/root/release-store.ts +48 -11
  294. package/src/runtime/root/runtime.ts +23 -5
  295. package/src/runtime/root/service.ts +100 -29
  296. package/src/runtime/root/types.ts +2 -0
  297. package/src/runtime/safe-tooling/ios-development.ts +321 -63
  298. package/src/runtime/shared/trusted-executable.ts +62 -0
  299. package/src/runtime/shared/trusted-node-executable.ts +48 -0
  300. package/src/runtime/standalone-recovery/core.ts +700 -92
  301. package/src/runtime/standalone-recovery/entry.ts +99 -23
  302. package/src/runtime/standalone-recovery/installer.ts +19 -3
  303. package/src/runtime/standalone-recovery/service-labels.ts +2 -0
  304. package/src/runtime/standalone-recovery/watchdog-heartbeat.ts +160 -0
  305. package/src/runtime/workflow/schedules/browser-probe.ts +81 -3
  306. package/src/runtime/workflow/schedules/engine.ts +287 -151
  307. package/src/runtime/workflow/schedules/github-issue-watch.ts +158 -0
  308. package/src/runtime/workflow/schedules/settlement.ts +48 -4
  309. package/src/runtime/workflow/schedules/store.ts +131 -10
  310. package/src/runtime/workflow/schedules/types.ts +2 -4
  311. package/src/runtime/workflow/schedules/work-continuation.ts +26 -13
  312. package/src/testing/test-governance.ts +8 -40
  313. package/assets/reference-configs/heartbeat-triage.md +0 -57
  314. package/assets/templates/helpers/harness-trace-grade.sh +0 -150
  315. package/assets/templates/helpers/heartbeat-triage.sh +0 -361
  316. package/assets/templates/helpers/maintenance-triage.sh +0 -167
  317. package/docs/architecture/current/approved-target-architecture.zh-CN.md +0 -1501
  318. package/docs/architecture/current/architecture-invariants.md +0 -331
  319. package/docs/architecture/current/automation-and-schedule-engine.md +0 -459
  320. package/docs/architecture/current/codegraph-context-provider.md +0 -238
  321. package/docs/architecture/current/control-plane-authority-inventory.md +0 -94
  322. package/docs/architecture/current/controller-repository-decoupling.md +0 -148
  323. package/docs/architecture/current/dispatch-and-agent-strategy.md +0 -373
  324. package/docs/architecture/current/entity-model.md +0 -612
  325. package/docs/architecture/current/external-plugin-governance.md +0 -396
  326. package/docs/architecture/current/failure-recovery.md +0 -447
  327. package/docs/architecture/current/governance.md +0 -213
  328. package/docs/architecture/current/human-interaction-plane.md +0 -87
  329. package/docs/architecture/current/implementation-status.md +0 -128
  330. package/docs/architecture/current/job-and-run-lifecycle.md +0 -413
  331. package/docs/architecture/current/legacy-governance.md +0 -58
  332. package/docs/architecture/current/local-workspace-target-grants.md +0 -70
  333. package/docs/architecture/current/migration-roadmap.md +0 -105
  334. package/docs/architecture/current/multi-repository-execution.md +0 -371
  335. package/docs/architecture/current/operations-runbook.md +0 -45
  336. package/docs/architecture/current/peer-supercontroller-runtime.md +0 -32
  337. package/docs/architecture/current/personal-assistant-plugin-baseline.md +0 -206
  338. package/docs/architecture/current/repository-access-modes.md +0 -102
  339. package/docs/architecture/current/route-policy.md +0 -15
  340. package/docs/architecture/current/route-session-concurrency.md +0 -85
  341. package/docs/architecture/current/runtime-architecture-simplification.md +0 -214
  342. package/docs/architecture/current/runtime-directory-map.md +0 -49
  343. package/docs/architecture/current/runtime-health-and-resource-lifecycle.md +0 -85
  344. package/docs/architecture/current/scheduler-and-resource-claims.md +0 -422
  345. package/docs/architecture/current/session-aware-execution-and-authorization.md +0 -77
  346. package/docs/architecture/current/system-overview.md +0 -169
  347. package/docs/architecture/current/target-requirements-traceability.md +0 -202
  348. package/docs/architecture/current/thin-harness-governance-policy.md +0 -32
  349. package/docs/architecture/current/thin-harness-v1.md +0 -321
  350. package/docs/architecture/current/verification-and-release-gates.md +0 -391
  351. package/docs/forge-autonomous-goal-loop.md +0 -9
  352. package/docs/forge-provider-configuration.md +0 -7
  353. package/docs/operations/chatgpt-personal-assistant-runtime.md +0 -93
  354. package/docs/operations/gmail-assistant-routines.md +0 -186
  355. package/docs/operations/personal-assistant-operator-guide.md +0 -61
  356. package/docs/operations/provider-configuration.md +0 -31
  357. package/scripts/harness-trace-grade.sh +0 -150
  358. package/scripts/heartbeat-triage.sh +0 -361
  359. package/scripts/maintenance-triage.sh +0 -167
  360. package/src/cli/local-bridge/frontend/src/features/settings/SettingsView.tsx +0 -2
  361. package/src/cli/repositories/goal-registry.ts +0 -239
  362. package/src/runtime/assistant/action-proposals.ts +0 -279
  363. package/src/runtime/assistant/intent.ts +0 -395
  364. package/src/runtime/assistant/model-provider.ts +0 -460
  365. package/src/runtime/assistant/openapi.ts +0 -223
  366. package/src/runtime/assistant/policy.ts +0 -89
  367. package/src/runtime/assistant/readiness.ts +0 -258
  368. package/src/runtime/assistant/routine-runtime.ts +0 -477
  369. package/src/runtime/assistant/schedule-binding.ts +0 -171
  370. package/src/runtime/assistant/standing-grants.ts +0 -375
  371. package/src/runtime/assistant/store.ts +0 -243
  372. package/src/runtime/assistant/types.ts +0 -135
  373. package/src/runtime/control-plane/goal-loop/config-facade.ts +0 -700
  374. package/src/runtime/control-plane/goal-loop/config-store.ts +0 -570
  375. package/src/runtime/control-plane/goal-loop/config-types.ts +0 -213
  376. package/src/runtime/control-plane/goal-loop/executor-router.ts +0 -109
  377. package/src/runtime/control-plane/goal-loop/failure-taxonomy.ts +0 -149
  378. package/src/runtime/control-plane/goal-loop/goal-contract-store.ts +0 -304
  379. package/src/runtime/control-plane/goal-loop/goal-loop-engine.ts +0 -1001
  380. package/src/runtime/control-plane/goal-loop/handoff-packet.ts +0 -160
  381. package/src/runtime/control-plane/goal-loop/index.ts +0 -13
  382. package/src/runtime/control-plane/goal-loop/local-tools.ts +0 -290
  383. package/src/runtime/control-plane/goal-loop/policy-gates.ts +0 -203
  384. package/src/runtime/control-plane/goal-loop/provider-dispatch.ts +0 -93
  385. package/src/runtime/control-plane/goal-loop/provider-registry.ts +0 -545
  386. package/src/runtime/control-plane/goal-loop/types.ts +0 -378
  387. package/src/runtime/control-plane/startup-recovery.ts +0 -198
  388. package/src/runtime/gateway/mcp/resource-policy.ts +0 -186
  389. package/src/runtime/personal-assistant/gmail-triage-manager.ts +0 -231
  390. package/src/runtime/personal-assistant/reporting-runtime.ts +0 -344
  391. package/src/runtime/personal-assistant/triage-runtime.ts +0 -748
  392. package/src/runtime/workflow/findings/store.ts +0 -204
  393. package/src/runtime/workflow/findings/types.ts +0 -58
  394. package/src/runtime/workflow/portfolio/engine.ts +0 -20
  395. package/src/runtime/workflow/portfolio/store.ts +0 -185
  396. package/src/runtime/workflow/portfolio/types.ts +0 -29
@@ -1,10 +1,12 @@
1
1
  import { createHash, randomUUID } from 'crypto';
2
2
  import { createRequire } from 'module';
3
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
4
- import { basename, dirname, join, relative, resolve } from 'path';
3
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'fs';
4
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'path';
5
+ import { readBrowserBinding } from '../../cli/chatgpt-browser/binding';
5
6
  import type {
6
7
  AssistantPluginActionDescriptor,
7
8
  AssistantPluginActionExecutionInput,
9
+ AssistantPluginAuthorizationContext,
8
10
  AssistantPluginCapability,
9
11
  AssistantPluginHealth,
10
12
  AssistantPluginManifest,
@@ -12,12 +14,34 @@ import type {
12
14
  } from './types';
13
15
  import { AssistantPluginError, toAssistantPluginError } from './errors';
14
16
  import { executeBrowserActionThroughNode, shouldUseBrowserNodeBridge } from './browser-node-bridge';
17
+ import { BrowserProviderUnavailableBeforeActionError } from './browser-provider-registry';
18
+ import {
19
+ ALL_BROWSER_PROVIDER_CAPABILITIES,
20
+ executeBrowserRuntimeAction,
21
+ invalidateBrowserRuntime,
22
+ } from './browser-runtime';
23
+ import { getExternalPluginAdapter } from './external-adapter';
24
+ import {
25
+ currentBrowserSessionAuthorityContext,
26
+ DEFAULT_BROWSER_SESSION_LIST_LIMIT,
27
+ findBrowserSession as findBrowserSessionAuthority,
28
+ listAllBrowserSessionsForRepository,
29
+ MAX_BROWSER_SESSION_LIST_LIMIT,
30
+ saveBrowserSession as saveBrowserSessionAuthority,
31
+ tombstoneBrowserSession,
32
+ withBrowserSessionAuthorityContext,
33
+ } from './browser-session-authority';
15
34
  import {
16
35
  closeMacOsBrowserOwnedTab,
17
36
  createMacOsBrowserOwnedPage,
18
37
  reattachMacOsBrowserOwnedPage,
19
38
  discoverMacOsBrowserAttachment,
39
+ invalidateMacOsBrowserPageHandle,
40
+ invalidateMacOsBrowserPageHandles,
41
+ macOsBrowserPageHandleStale,
20
42
  getMacOsBrowserAttachObservation,
43
+ listMacOsBrowserTabs,
44
+ readMacOsBrowserOwnedTabMetadata,
21
45
  macOsActiveBrowserAttachSupported,
22
46
  macOsBrowserJavaScriptAutomationDisabled,
23
47
  type MacOsBrowserAttachAttempt,
@@ -29,9 +53,12 @@ import {
29
53
  assertBrowserSessionAvailable,
30
54
  cancelBrowserHandoff,
31
55
  getBrowserHandoff,
56
+ isRuntimeManagedBrowserHandoff,
32
57
  listBrowserHandoffs,
58
+ resolveRuntimeManagedBrowserHandoff,
33
59
  resumeBrowserHandoff,
34
60
  startBrowserHandoff,
61
+ startRuntimeManagedBrowserHandoff,
35
62
  } from './browser-handoff';
36
63
 
37
64
  const BROWSER_PLUGIN_ID = 'browser';
@@ -92,6 +119,7 @@ interface BrowserSessionConnectionState {
92
119
  endpoint?: string;
93
120
  browserVersion?: string;
94
121
  browserProduct?: MacOsBrowserProduct;
122
+ nativeBrowserCandidates?: MacOsBrowserProduct[];
95
123
  fallback?: BrowserConnectionFallback;
96
124
  tab?: BrowserTabResumeState;
97
125
  sessionResume?: BrowserSessionResumeDiagnostic;
@@ -107,6 +135,30 @@ interface BrowserSessionState {
107
135
  browser?: BrowserSessionConnectionState;
108
136
  }
109
137
 
138
+ type BrowserSessionLiveness = 'live' | 'unverified' | 'dead';
139
+
140
+ type BrowserSessionInventoryItem = {
141
+ session: BrowserSessionState;
142
+ liveness: BrowserSessionLiveness;
143
+ evidence: 'runtime_session_binding' | 'runtime_owner_token' | 'runtime_bound_page_missing' | 'runtime_context_unavailable' | 'runtime_owner_token_not_observed' | 'provider_unverified' | 'native_tab_live' | 'native_tab_missing' | 'native_tab_invalid' | 'native_tab_invalid_closed' | 'native_tab_invalid_close_failed' | 'native_inventory_unavailable';
144
+ pruned?: boolean;
145
+ cleanupError?: string;
146
+ };
147
+
148
+ type BrowserSessionInventory = {
149
+ sessions: BrowserSessionInventoryItem[];
150
+ scannedSessionCount: number;
151
+ savedSessionCount: number;
152
+ liveManagedSessionCount: number;
153
+ liveNativeSessionCount: number;
154
+ unverifiedSessionCount: number;
155
+ deadManagedSessionCount: number;
156
+ deadNativeSessionCount: number;
157
+ closedInvalidNativeSessionCount: number;
158
+ failedNativeCleanupCount: number;
159
+ prunedSessionCount: number;
160
+ };
161
+
110
162
  interface BrowserActionTarget {
111
163
  sessionId: string;
112
164
  url: string;
@@ -138,6 +190,20 @@ type BrowserLike = {
138
190
  disconnect?(): Promise<void> | void;
139
191
  };
140
192
 
193
+ type FrameLike = {
194
+ url(): string;
195
+ name(): string;
196
+ evaluate<T>(expression: string | ((...args: unknown[]) => unknown), arg?: unknown): Promise<T>;
197
+ frameElement?(): Promise<{ boundingBox(): Promise<{ x: number; y: number; width: number; height: number } | null> }>;
198
+ };
199
+
200
+ type BrowserDownloadLike = {
201
+ suggestedFilename(): string;
202
+ saveAs(path: string): Promise<void>;
203
+ failure?(): Promise<string | null>;
204
+ delete?(): Promise<void>;
205
+ };
206
+
141
207
  type PageLike = {
142
208
  goto(url: string, options?: Record<string, unknown>): Promise<unknown>;
143
209
  reload?(options?: Record<string, unknown>): Promise<unknown>;
@@ -160,10 +226,24 @@ type PageLike = {
160
226
  uncheck?(selector: string, options?: Record<string, unknown>): Promise<void>;
161
227
  waitForSelector(selector: string, options?: Record<string, unknown>): Promise<unknown>;
162
228
  waitForLoadState?(state?: string, options?: Record<string, unknown>): Promise<void>;
229
+ waitForEvent?(event: 'download', options?: Record<string, unknown>): Promise<BrowserDownloadLike>;
230
+ frames?(): FrameLike[];
231
+ mainFrame?(): FrameLike;
163
232
  locator?(selector: string): { screenshot(options?: Record<string, unknown>): Promise<Buffer> };
164
233
  on?(event: string, handler: (...args: unknown[]) => void): void;
165
234
  bringToFront?(): Promise<void>;
166
- keyboard?: { press(key: string): Promise<void> };
235
+ foregroundState?(): Promise<{ frontmost: boolean; active: boolean }>;
236
+ trustedInput?(input: Record<string, unknown>): Promise<void>;
237
+ mouse?: {
238
+ click(x: number, y: number, options?: { button?: 'left' | 'middle' | 'right'; clickCount?: number }): Promise<void>;
239
+ move(x: number, y: number, options?: { steps?: number }): Promise<void>;
240
+ wheel(deltaX: number, deltaY: number): Promise<void>;
241
+ down(options?: { button?: 'left' | 'middle' | 'right' }): Promise<void>;
242
+ up(options?: { button?: 'left' | 'middle' | 'right' }): Promise<void>;
243
+ };
244
+ keyboard?: { press(key: string): Promise<void>; insertText?(text: string): Promise<void> };
245
+ setInputFiles?: (selector: string, files: string | string[]) => Promise<void>;
246
+ close?(): Promise<void>;
167
247
  };
168
248
 
169
249
  type PlaywrightRuntime = {
@@ -175,29 +255,73 @@ type PlaywrightRuntime = {
175
255
 
176
256
  interface BrowserPluginRuntimeHooks {
177
257
  now(): string;
178
- moduleAvailable(name: string): boolean;
179
- loadPlaywright(): PlaywrightRuntime;
258
+ moduleAvailable(name: string, repoRoot?: string): boolean;
259
+ loadPlaywright(repoRoot?: string): PlaywrightRuntime;
180
260
  fetchJson(url: string, timeoutMs: number): Promise<unknown>;
261
+ activateNativeBrowserApplication(input: AssistantPluginActionExecutionInput, product: MacOsBrowserProduct): Promise<void>;
262
+ }
263
+
264
+ const NATIVE_BROWSER_BUNDLE_IDS: Record<MacOsBrowserProduct, string> = {
265
+ chrome: 'com.google.Chrome',
266
+ vivaldi: 'com.vivaldi.Vivaldi',
267
+ };
268
+
269
+ async function activateNativeBrowserApplicationViaDesktopOperator(
270
+ input: AssistantPluginActionExecutionInput,
271
+ product: MacOsBrowserProduct,
272
+ ): Promise<void> {
273
+ const desktopOperator = getExternalPluginAdapter(input.controllerHome, 'desktop_operator');
274
+ if (!desktopOperator) {
275
+ throw new AssistantPluginError(
276
+ 'PLUGIN_BROWSER_NATIVE_FOREGROUND_ACTIVATOR_UNAVAILABLE',
277
+ 'Native browser foreground activation requires the registered Forge Desktop Operator.',
278
+ { retryable: true, details: { browserProduct: product } },
279
+ );
280
+ }
281
+ await desktopOperator.executeAction({
282
+ controllerHome: input.controllerHome,
283
+ repoId: input.repoId,
284
+ repoRoot: input.repoRoot,
285
+ pluginId: 'desktop_operator',
286
+ actionId: 'desktop_session_open',
287
+ requestId: `${input.requestId}:native-browser-foreground:${product}`,
288
+ args: { bundle_id: NATIVE_BROWSER_BUNDLE_IDS[product], launch: false, activate: true },
289
+ origin: input.origin,
290
+ jobId: input.jobId,
291
+ timeoutMs: input.timeoutMs,
292
+ signal: input.signal,
293
+ deadlineAtMs: input.deadlineAtMs,
294
+ });
181
295
  }
182
296
 
183
297
  const defaultRuntimeHooks: BrowserPluginRuntimeHooks = {
184
298
  now: () => new Date().toISOString(),
185
- moduleAvailable: (name: string) => {
186
- try {
187
- createRequire(import.meta.url).resolve(name);
188
- return true;
189
- } catch {
190
- return false;
299
+ moduleAvailable: (name: string, repoRoot?: string) => {
300
+ const anchors = [repoRoot ? join(repoRoot, 'package.json') : undefined, import.meta.url]
301
+ .filter((value): value is string => Boolean(value));
302
+ for (const anchor of anchors) {
303
+ try {
304
+ createRequire(anchor).resolve(name);
305
+ return true;
306
+ } catch {
307
+ // Try the next trusted dependency anchor.
308
+ }
191
309
  }
310
+ return false;
192
311
  },
193
- loadPlaywright: () => {
194
- try {
195
- return createRequire(import.meta.url)('playwright') as PlaywrightRuntime;
196
- } catch {
197
- throw new AssistantPluginError('PLUGIN_DEPENDENCY_MISSING', 'Browser plugin requires playwright. Run bun install before using browser actions.', {
198
- retryable: false,
199
- });
312
+ loadPlaywright: (repoRoot?: string) => {
313
+ const anchors = [repoRoot ? join(repoRoot, 'package.json') : undefined, import.meta.url]
314
+ .filter((value): value is string => Boolean(value));
315
+ for (const anchor of anchors) {
316
+ try {
317
+ return createRequire(anchor)('playwright') as PlaywrightRuntime;
318
+ } catch {
319
+ // Try the next trusted dependency anchor.
320
+ }
200
321
  }
322
+ throw new AssistantPluginError('PLUGIN_DEPENDENCY_MISSING', 'Browser plugin requires playwright. Run bun install before using browser actions.', {
323
+ retryable: false,
324
+ });
201
325
  },
202
326
  fetchJson: async (url: string, timeoutMs: number) => {
203
327
  const controller = new AbortController();
@@ -210,11 +334,21 @@ const defaultRuntimeHooks: BrowserPluginRuntimeHooks = {
210
334
  clearTimeout(timer);
211
335
  }
212
336
  },
337
+ activateNativeBrowserApplication: activateNativeBrowserApplicationViaDesktopOperator,
213
338
  };
214
339
 
215
340
  let runtimeHooks: BrowserPluginRuntimeHooks = { ...defaultRuntimeHooks };
216
341
  let runtimeHooksCustomized = false;
217
342
 
343
+ interface ManagedBrowserContextState {
344
+ repoRoot: string;
345
+ profilePath: string;
346
+ context: BrowserContextLike;
347
+ pagesBySession: Map<string, PageLike>;
348
+ }
349
+
350
+ const managedBrowserContexts = new Map<string, Promise<ManagedBrowserContextState>>();
351
+
218
352
  export function setBrowserPluginRuntimeHooksForTest(hooks: Partial<BrowserPluginRuntimeHooks>): void {
219
353
  runtimeHooks = { ...defaultRuntimeHooks, ...hooks };
220
354
  runtimeHooksCustomized = true;
@@ -223,6 +357,7 @@ export function setBrowserPluginRuntimeHooksForTest(hooks: Partial<BrowserPlugin
223
357
  export function resetBrowserPluginRuntimeHooksForTest(): void {
224
358
  runtimeHooks = { ...defaultRuntimeHooks };
225
359
  runtimeHooksCustomized = false;
360
+ managedBrowserContexts.clear();
226
361
  }
227
362
 
228
363
  function now(): string {
@@ -237,6 +372,13 @@ function positiveNumber(value: unknown, fallback: number): number {
237
372
  return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.trunc(value) : fallback;
238
373
  }
239
374
 
375
+ function requiredFiniteNumber(value: unknown, name: string, min = -100_000, max = 100_000): number {
376
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max) {
377
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', `${name} must be a finite number between ${min} and ${max}.`, { retryable: false });
378
+ }
379
+ return value;
380
+ }
381
+
240
382
  function browserProfileMode(value: unknown): BrowserProfileMode | undefined {
241
383
  return value === 'repo_local' || value === 'custom' ? value : undefined;
242
384
  }
@@ -331,7 +473,7 @@ function normalizeConfig(raw: Partial<BrowserPluginConfig>): BrowserPluginConfig
331
473
  schemaVersion: 1,
332
474
  enabled: raw.enabled === true,
333
475
  provider: 'playwright',
334
- browserMode: browserMode(raw.browserMode) ?? 'managed_persistent',
476
+ browserMode: browserMode(raw.browserMode) ?? 'attach_preferred',
335
477
  profileMode: browserProfileMode(raw.profileMode) ?? (normalizedProfileDir ? 'custom' : 'repo_local'),
336
478
  profileDir: normalizedProfileDir,
337
479
  profileDirectory: stringValue(raw.profileDirectory),
@@ -344,7 +486,7 @@ function normalizeConfig(raw: Partial<BrowserPluginConfig>): BrowserPluginConfig
344
486
  : undefined,
345
487
  cdpAttachFallback: browserCdpAttachFallback(raw.cdpAttachFallback) ?? 'fail_closed',
346
488
  nativeAttachMode: browserNativeAttachMode(raw.nativeAttachMode) ?? 'auto',
347
- nativeBrowserCandidates: browserProductList(raw.nativeBrowserCandidates) ?? ['vivaldi', 'chrome'],
489
+ nativeBrowserCandidates: browserProductList(raw.nativeBrowserCandidates) ?? ['chrome', 'vivaldi'],
348
490
  defaultTimeoutMs: typeof raw.defaultTimeoutMs === 'number' ? positiveNumber(raw.defaultTimeoutMs, DEFAULT_TIMEOUT_MS) : undefined,
349
491
  };
350
492
  }
@@ -362,8 +504,119 @@ function writeJson(path: string, value: unknown): void {
362
504
  writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, 'utf-8');
363
505
  }
364
506
 
507
+ function writeAtomicJson(path: string, value: unknown): void {
508
+ mkdirSync(dirname(path), { recursive: true });
509
+ const tempPath = join(dirname(path), `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
510
+ try {
511
+ writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf-8');
512
+ renameSync(tempPath, path);
513
+ } finally {
514
+ rmSync(tempPath, { force: true });
515
+ }
516
+ }
517
+
518
+ function readBrowserSessionJson(path: string): BrowserSessionState | undefined {
519
+ let raw: string;
520
+ try {
521
+ raw = readFileSync(path, 'utf-8');
522
+ } catch (error) {
523
+ if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') return undefined;
524
+ throw new AssistantPluginError('PLUGIN_BROWSER_SESSION_STATE_READ_FAILED', 'Saved browser session metadata could not be read.', {
525
+ retryable: true,
526
+ details: { fileName: basename(path), cause: error instanceof Error ? error.message : String(error) },
527
+ });
528
+ }
529
+ try {
530
+ return JSON.parse(raw) as BrowserSessionState;
531
+ } catch (error) {
532
+ throw new AssistantPluginError('PLUGIN_BROWSER_SESSION_STATE_CORRUPT', 'Saved browser session metadata is malformed; refusing to treat corrupt state as a missing session.', {
533
+ retryable: false,
534
+ details: { fileName: basename(path), cause: error instanceof Error ? error.message : String(error) },
535
+ });
536
+ }
537
+ }
538
+
539
+ function legacyChatgptBindingConfig(repoRoot: string): Partial<BrowserPluginConfig> {
540
+ const binding = readBrowserBinding(repoRoot).binding;
541
+ if (!binding) return {};
542
+ return {
543
+ profileMode: 'custom',
544
+ profileDir: binding.profileDir,
545
+ profileDirectory: binding.profileDirectory,
546
+ browserChannel: browserChannel(binding.browserChannel) ?? 'chromium',
547
+ };
548
+ }
549
+
365
550
  function loadConfig(repoRoot: string): BrowserPluginConfig {
366
- return normalizeConfig(readJson<Partial<BrowserPluginConfig>>(configPath(repoRoot)) ?? {});
551
+ const persisted = readJson<Partial<BrowserPluginConfig>>(configPath(repoRoot));
552
+ if (!persisted) return normalizeConfig(legacyChatgptBindingConfig(repoRoot));
553
+ const merged: Partial<BrowserPluginConfig> = { ...legacyChatgptBindingConfig(repoRoot), ...persisted };
554
+ if (browserProfileMode(persisted.profileMode) === 'repo_local') {
555
+ merged.profileDir = undefined;
556
+ merged.profileDirectory = undefined;
557
+ }
558
+ return normalizeConfig(merged);
559
+ }
560
+
561
+ async function resolveBrowserPluginAuthorizationContextInternal(
562
+ input: AssistantPluginActionExecutionInput,
563
+ ): Promise<AssistantPluginAuthorizationContext | undefined> {
564
+ const action = actions().find((entry) => entry.actionId === input.actionId);
565
+ if (!action || action.confirmation !== 'authorization' || !action.scopes.includes('browser.interact')) return undefined;
566
+
567
+ const persistedConfig = loadConfig(input.repoRoot);
568
+ const sessionId = stringValue(input.args.session_id);
569
+ const authorizationBrowserMode = parseBrowserModeInput(input.args.browser_mode) ?? persistedConfig.browserMode;
570
+ if (!sessionId || authorizationBrowserMode === 'isolated') return undefined;
571
+ const session = findSession(input.repoRoot, sessionId);
572
+ const config = effectiveBrowserActionConfig(persistedConfig, input.args, session);
573
+ const connection = session?.browser;
574
+ if (!session || !connection || connection.activeMode === 'isolated') return undefined;
575
+
576
+ let origin: string;
577
+ try {
578
+ const destinationUrl = input.actionId === 'navigate' ? stringValue(input.args.url) : undefined;
579
+ const parsed = new URL(destinationUrl ?? session.url);
580
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return undefined;
581
+ origin = parsed.origin.toLowerCase();
582
+ } catch {
583
+ return undefined;
584
+ }
585
+
586
+ const profile = selectedProfile(config, input.repoRoot, connection.activeMode, sessionId);
587
+ const providerId = connection.browserProduct ?? connection.provider;
588
+ const identity = {
589
+ provider: connection.provider,
590
+ browserProduct: connection.browserProduct,
591
+ activeMode: connection.activeMode,
592
+ endpoint: connection.endpoint,
593
+ origin,
594
+ browserChannel: config.browserChannel,
595
+ executablePath: config.executablePath ? resolveConfiguredPath(input.repoRoot, config.executablePath) : undefined,
596
+ profileMode: config.profileMode,
597
+ selectedProfilePath: profile.selectedProfilePath,
598
+ profileDirectory: profile.profileDirectory,
599
+ cdpAttachFallback: config.cdpAttachFallback,
600
+ nativeAttachMode: config.nativeAttachMode,
601
+ nativeBrowserCandidates: config.nativeBrowserCandidates,
602
+ };
603
+ return {
604
+ target: {
605
+ kind: 'browser-origin',
606
+ id: `${providerId}@${origin}`,
607
+ identityFingerprint: createHash('sha256').update(JSON.stringify(identity)).digest('hex'),
608
+ },
609
+ expiresInMinutes: 30 * 24 * 60,
610
+ };
611
+ }
612
+
613
+ export async function resolveBrowserPluginAuthorizationContext(
614
+ input: AssistantPluginActionExecutionInput,
615
+ ): Promise<AssistantPluginAuthorizationContext | undefined> {
616
+ return await withBrowserSessionAuthorityContext(
617
+ { controllerHome: input.controllerHome, repoId: input.repoId },
618
+ () => resolveBrowserPluginAuthorizationContextInternal(input),
619
+ );
367
620
  }
368
621
 
369
622
  export function readBrowserPluginConfiguration(repoRoot: string): { enabled: boolean } {
@@ -381,12 +634,59 @@ function sessionPath(repoRoot: string, sessionId: string): string {
381
634
  return join(stateDir(repoRoot, 'sessions'), `${sessionId.replace(/[^a-zA-Z0-9_-]/g, '_')}.json`);
382
635
  }
383
636
 
384
- function saveSession(repoRoot: string, session: BrowserSessionState): void {
385
- writeJson(sessionPath(repoRoot, session.sessionId), session);
637
+ function saveSession(repoRoot: string, session: BrowserSessionState): BrowserSessionState {
638
+ const authority = currentBrowserSessionAuthorityContext();
639
+ if (!authority) {
640
+ writeAtomicJson(sessionPath(repoRoot, session.sessionId), session);
641
+ return session;
642
+ }
643
+ const saved = saveBrowserSessionAuthority<BrowserSessionState>(authority.controllerHome, authority.repoId, repoRoot, session);
644
+ if (saved.sessionId === session.sessionId || !saved.browser?.sessionResume) return saved;
645
+ return {
646
+ ...saved,
647
+ browser: {
648
+ ...saved.browser,
649
+ sessionResume: { ...saved.browser.sessionResume, sessionId: saved.sessionId },
650
+ },
651
+ };
386
652
  }
387
653
 
388
654
  function findSession(repoRoot: string, sessionId: string): BrowserSessionState | undefined {
389
- return readJson<BrowserSessionState>(sessionPath(repoRoot, sessionId));
655
+ const authority = currentBrowserSessionAuthorityContext();
656
+ return authority
657
+ ? findBrowserSessionAuthority<BrowserSessionState>(authority.controllerHome, authority.repoId, repoRoot, sessionId)
658
+ : readBrowserSessionJson(sessionPath(repoRoot, sessionId));
659
+ }
660
+
661
+ function listSavedBrowserSessions(repoRoot: string): BrowserSessionState[] {
662
+ const authority = currentBrowserSessionAuthorityContext();
663
+ if (authority) {
664
+ return listAllBrowserSessionsForRepository<BrowserSessionState>(authority.controllerHome, authority.repoId, repoRoot);
665
+ }
666
+ const root = stateDir(repoRoot, 'sessions');
667
+ let names: string[];
668
+ try {
669
+ names = readdirSync(root);
670
+ } catch (error) {
671
+ if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') return [];
672
+ throw new AssistantPluginError('PLUGIN_BROWSER_SESSION_STATE_READ_FAILED', 'Saved browser session directory could not be read.', {
673
+ retryable: true,
674
+ details: { cause: error instanceof Error ? error.message : String(error) },
675
+ });
676
+ }
677
+ return names
678
+ .filter((name) => name.endsWith('.json'))
679
+ .map((name) => readBrowserSessionJson(join(root, name)))
680
+ .filter((session): session is BrowserSessionState => Boolean(session));
681
+ }
682
+
683
+ function removeSession(repoRoot: string, sessionId: string): void {
684
+ const authority = currentBrowserSessionAuthorityContext();
685
+ if (authority) {
686
+ tombstoneBrowserSession(authority.controllerHome, authority.repoId, repoRoot, sessionId);
687
+ return;
688
+ }
689
+ rmSync(sessionPath(repoRoot, sessionId), { force: true });
390
690
  }
391
691
 
392
692
  function loadSession(repoRoot: string, sessionId: string): BrowserSessionState {
@@ -397,21 +697,65 @@ function loadSession(repoRoot: string, sessionId: string): BrowserSessionState {
397
697
  return state;
398
698
  }
399
699
 
400
- function normalizedUrl(value: unknown): string {
700
+ function parsedAbsoluteUrl(value: unknown): { raw: string; parsed: URL } {
401
701
  const raw = stringValue(value);
402
702
  if (!raw) throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'url is required.', { retryable: false });
403
- let parsed: URL;
404
703
  try {
405
- parsed = new URL(raw);
704
+ return { raw, parsed: new URL(raw) };
406
705
  } catch {
407
706
  throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'url must be absolute.', { retryable: false });
408
707
  }
409
- if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
410
- throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'Only http and https URLs are supported.', { retryable: false });
411
- }
708
+ }
709
+
710
+ function unsupportedUrlProtocol(value: unknown, raw: string, protocol: string): never {
711
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', `Only http and https URLs are supported; observed protocol: ${protocol}`, {
712
+ retryable: false,
713
+ details: {
714
+ observedProtocol: protocol,
715
+ inputType: typeof value,
716
+ inputLength: raw.length,
717
+ },
718
+ });
719
+ }
720
+
721
+ function normalizedUrl(value: unknown): string {
722
+ const { raw, parsed } = parsedAbsoluteUrl(value);
723
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') unsupportedUrlProtocol(value, raw, parsed.protocol);
412
724
  return parsed.toString();
413
725
  }
414
726
 
727
+ function normalizedExplicitNativeAdoptionUrl(value: unknown): string {
728
+ const { raw, parsed } = parsedAbsoluteUrl(value);
729
+ if (!['https:', 'http:', 'chrome:', 'vivaldi:'].includes(parsed.protocol)) unsupportedUrlProtocol(value, raw, parsed.protocol);
730
+ return parsed.toString();
731
+ }
732
+
733
+ function browserInternalUrlProduct(value: unknown): MacOsBrowserProduct | undefined {
734
+ const { parsed } = parsedAbsoluteUrl(value);
735
+ return parsed.protocol === 'chrome:' ? 'chrome' : parsed.protocol === 'vivaldi:' ? 'vivaldi' : undefined;
736
+ }
737
+
738
+ function explicitNativeAdoptionUrlsMatch(requestedValue: unknown, observedValue: unknown, product: MacOsBrowserProduct): boolean {
739
+ const requestedUrl = normalizedExplicitNativeAdoptionUrl(requestedValue);
740
+ const observedUrl = normalizedExplicitNativeAdoptionUrl(observedValue);
741
+ if (comparableUrl(requestedUrl) === comparableUrl(observedUrl)) return true;
742
+ return product === 'vivaldi'
743
+ && requestedUrl === 'vivaldi:bookmarks'
744
+ && comparableUrl(observedUrl) === comparableUrl('chrome://bookmarks/');
745
+ }
746
+
747
+ function isExplicitNativeUserOwnedSession(session: BrowserSessionState): boolean {
748
+ return session.browser?.provider === 'macos-apple-events'
749
+ && session.browser.tab?.ownership === 'user_owned'
750
+ && (session.browser.browserProduct === 'chrome' || session.browser.browserProduct === 'vivaldi');
751
+ }
752
+
753
+ function normalizedSavedSessionUrl(session: BrowserSessionState): string {
754
+ return isExplicitNativeUserOwnedSession(session)
755
+ ? normalizedExplicitNativeAdoptionUrl(session.url)
756
+ : normalizedUrl(session.url);
757
+ }
758
+
415
759
 
416
760
  function waitUntil(value: unknown): WaitUntil {
417
761
  return value === 'load' || value === 'networkidle' || value === 'domcontentloaded' ? value : 'domcontentloaded';
@@ -463,6 +807,35 @@ function parseNativeBrowserCandidatesInput(value: unknown): MacOsBrowserProduct[
463
807
  throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'native_browser_candidates must contain chrome and/or vivaldi.', { retryable: false });
464
808
  }
465
809
 
810
+ function effectiveBrowserActionConfig(
811
+ config: BrowserPluginConfig,
812
+ args: Record<string, unknown>,
813
+ existingSession?: BrowserSessionState,
814
+ ): BrowserPluginConfig {
815
+ const explicitBrowserMode = parseBrowserModeInput(args.browser_mode);
816
+ const explicitNativeBrowserCandidates = parseNativeBrowserCandidatesInput(args.native_browser_candidates);
817
+ const existingNativeProduct = existingSession?.browser?.provider === 'macos-apple-events'
818
+ ? existingSession.browser.browserProduct
819
+ : undefined;
820
+ if (existingNativeProduct && explicitNativeBrowserCandidates && !explicitNativeBrowserCandidates.includes(existingNativeProduct)) {
821
+ throw new AssistantPluginError(
822
+ 'PLUGIN_ACTION_ARGUMENT_INVALID',
823
+ 'native_browser_candidates cannot switch browser products for an existing native browser session.',
824
+ { retryable: false, details: { sessionId: existingSession?.sessionId, browserProduct: existingNativeProduct, nativeBrowserCandidates: explicitNativeBrowserCandidates } },
825
+ );
826
+ }
827
+ const savedNativeBrowserCandidates = existingSession?.browser?.provider === 'macos-apple-events'
828
+ ? existingSession.browser.nativeBrowserCandidates ?? (existingNativeProduct ? [existingNativeProduct] : undefined)
829
+ : undefined;
830
+ return {
831
+ ...config,
832
+ browserMode: explicitBrowserMode ?? existingSession?.browser?.mode ?? config.browserMode,
833
+ cdpAttachFallback: parseCdpAttachFallbackInput(args.cdp_attach_fallback) ?? config.cdpAttachFallback,
834
+ nativeAttachMode: parseNativeAttachModeInput(args.native_attach_mode) ?? config.nativeAttachMode,
835
+ nativeBrowserCandidates: explicitNativeBrowserCandidates ?? savedNativeBrowserCandidates ?? config.nativeBrowserCandidates,
836
+ };
837
+ }
838
+
466
839
  function cdpEndpoints(config: BrowserPluginConfig): string[] {
467
840
  return Array.from(new Set([
468
841
  ...(config.cdpEndpoint ? [config.cdpEndpoint] : []),
@@ -567,11 +940,34 @@ function requiredString(value: unknown, field: string): string {
567
940
  return normalized;
568
941
  }
569
942
 
943
+ function openShadowSelectorParts(selector: string): string[] {
944
+ return selector.split(/\s*>>>\s*/).map((part) => part.trim()).filter(Boolean);
945
+ }
946
+
947
+ function openShadowSelectorExpression(selector: string, all = false): string {
948
+ const parts = openShadowSelectorParts(selector);
949
+ if (parts.length <= 1) {
950
+ return all
951
+ ? `Array.from(document.querySelectorAll(${JSON.stringify(selector)}))`
952
+ : `document.querySelector(${JSON.stringify(selector)})`;
953
+ }
954
+ return `(() => {
955
+ const parts = ${JSON.stringify(parts)};
956
+ let root = document;
957
+ for (let index = 0; index < parts.length - 1; index += 1) {
958
+ const host = root.querySelector(parts[index]);
959
+ if (!host || !host.shadowRoot) return ${all ? '[]' : 'null'};
960
+ root = host.shadowRoot;
961
+ }
962
+ return ${all ? 'Array.from(root.querySelectorAll(parts[parts.length - 1]))' : 'root.querySelector(parts[parts.length - 1])'};
963
+ })()`;
964
+ }
965
+
570
966
  function scriptText(selector?: string): string {
571
967
  if (!selector) {
572
968
  return 'document.body ? document.body.innerText : (document.documentElement ? document.documentElement.textContent : "")';
573
969
  }
574
- return `(() => { const el = document.querySelector(${JSON.stringify(selector)}); return el ? (el.innerText || el.textContent || '') : ''; })()`;
970
+ return `(() => { const el = ${openShadowSelectorExpression(selector)}; return el ? (el.innerText || el.textContent || '') : ''; })()`;
575
971
  }
576
972
 
577
973
  function resolveActionTarget(repoRoot: string, args: Record<string, unknown>): BrowserActionTarget {
@@ -582,9 +978,10 @@ function resolveActionTarget(repoRoot: string, args: Record<string, unknown>): B
582
978
  }
583
979
  if (providedSessionId) {
584
980
  const existingSession = loadSession(repoRoot, providedSessionId);
585
- const sessionUrl = normalizedUrl(existingSession.url);
981
+ const nativeUserOwned = isExplicitNativeUserOwnedSession(existingSession);
982
+ const sessionUrl = normalizedSavedSessionUrl(existingSession);
586
983
  if (directUrl) {
587
- const explicitUrl = normalizedUrl(directUrl);
984
+ const explicitUrl = nativeUserOwned ? normalizedExplicitNativeAdoptionUrl(directUrl) : normalizedUrl(directUrl);
588
985
  if (explicitUrl !== sessionUrl) {
589
986
  throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'url does not match the saved session.', { retryable: false });
590
987
  }
@@ -617,6 +1014,27 @@ function screenshotFilePath(repoRoot: string, actionId: string, sessionId: strin
617
1014
  return join(screenshotDir, `${Date.now()}-${actionLabel}-${sessionLabel}-${digest}.png`);
618
1015
  }
619
1016
 
1017
+ function safeDownloadArtifactName(requestedName: string | undefined, browserSuggestedName: string | undefined): string {
1018
+ const fallback = `download-${Date.now()}`;
1019
+ const raw = (requestedName || browserSuggestedName || fallback).trim();
1020
+ const leaf = basename(raw) || fallback;
1021
+ const sanitized = leaf.replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 180);
1022
+ return sanitized && sanitized !== '.' && sanitized !== '..' ? sanitized : fallback;
1023
+ }
1024
+
1025
+ function isBlockedExecutableArtifactName(name: string | undefined): boolean {
1026
+ return Boolean(name && /\.(exe|dmg|pkg|sh|bat|cmd|app)$/i.test(basename(name)));
1027
+ }
1028
+
1029
+ function uniqueDownloadArtifactPath(downloadDir: string, fileName: string): string {
1030
+ const initial = join(downloadDir, fileName);
1031
+ if (!existsSync(initial)) return initial;
1032
+ const dot = fileName.lastIndexOf('.');
1033
+ const stem = dot > 0 ? fileName.slice(0, dot) : fileName;
1034
+ const ext = dot > 0 ? fileName.slice(dot) : '';
1035
+ return join(downloadDir, `${stem}-${Date.now()}${ext}`);
1036
+ }
1037
+
620
1038
  async function captureActionScreenshot(page: PageLike, repoRoot: string, actionId: string, sessionId: string, url: string): Promise<BrowserActionScreenshot | undefined> {
621
1039
  const path = screenshotFilePath(repoRoot, actionId, sessionId, url);
622
1040
  const bytes = (await page.screenshot({ path, fullPage: true })).length;
@@ -627,6 +1045,10 @@ function responseWithWarnings(base: Record<string, unknown>, warnings: string[])
627
1045
  return warnings.length > 0 ? { ...base, warnings } : base;
628
1046
  }
629
1047
 
1048
+ function browserResultProvider(provider: BrowserSessionConnectionState['provider'] | BrowserConnectionSummary['provider'] | undefined): 'playwright' | 'macos-apple-events' {
1049
+ return provider === 'macos-apple-events' ? 'macos-apple-events' : 'playwright';
1050
+ }
1051
+
630
1052
  function interactionResult(
631
1053
  actionId: string,
632
1054
  session: BrowserSessionState,
@@ -636,7 +1058,7 @@ function interactionResult(
636
1058
  extra: Record<string, unknown> = {},
637
1059
  ): Record<string, unknown> {
638
1060
  return responseWithWarnings({
639
- provider: 'playwright',
1061
+ provider: browserResultProvider(session.browser?.provider),
640
1062
  session,
641
1063
  url: session.url,
642
1064
  title: session.title,
@@ -678,7 +1100,7 @@ function selectedProfile(config: BrowserPluginConfig, repoRoot: string, activeMo
678
1100
  };
679
1101
  }
680
1102
 
681
- type BrowserTabMatchReason = 'owned_token' | 'saved_url_title' | 'saved_url' | 'exact_url' | 'blank' | 'new_page';
1103
+ type BrowserTabMatchReason = 'owned_token' | 'recovered_tab' | 'saved_url_title' | 'saved_url' | 'exact_url' | 'blank' | 'new_page';
682
1104
 
683
1105
  const BROWSER_OWNER_PREFIX = 'forge-browser-owned:';
684
1106
 
@@ -726,6 +1148,7 @@ interface BrowserConnectionSummary {
726
1148
  endpoint?: string;
727
1149
  browserVersion?: string;
728
1150
  browserProduct?: MacOsBrowserProduct;
1151
+ nativeBrowserCandidates?: MacOsBrowserProduct[];
729
1152
  fallback?: BrowserConnectionFallback;
730
1153
  profile: {
731
1154
  profileMode?: BrowserProfileMode;
@@ -737,6 +1160,62 @@ interface BrowserConnectionSummary {
737
1160
  sessionResume?: BrowserSessionResumeDiagnostic;
738
1161
  }
739
1162
 
1163
+ async function establishAuthoritativeNativeForeground(
1164
+ input: AssistantPluginActionExecutionInput,
1165
+ page: PageLike,
1166
+ connection: BrowserConnectionSummary,
1167
+ target: BrowserActionTarget,
1168
+ waitMs: number,
1169
+ ): Promise<{ frontmost: boolean; active: boolean }> {
1170
+ if (!page.bringToFront || !page.foregroundState) {
1171
+ throw new AssistantPluginError(
1172
+ 'PLUGIN_BROWSER_ACTIVATION_UNSUPPORTED',
1173
+ 'The native browser provider cannot verify exact-tab foreground authority.',
1174
+ { retryable: false, details: { sessionId: target.sessionId, browserProduct: connection.browserProduct } },
1175
+ );
1176
+ }
1177
+
1178
+ await page.bringToFront();
1179
+ await delay(waitMs);
1180
+ let state = await page.foregroundState();
1181
+ if (state.frontmost && state.active) return state;
1182
+
1183
+ const product = connection.browserProduct;
1184
+ if (!product) {
1185
+ throw new AssistantPluginError(
1186
+ 'PLUGIN_BROWSER_ACTIVATION_FAILED',
1187
+ 'Native browser activation could not identify the browser product for system foreground recovery.',
1188
+ { retryable: true, details: { sessionId: target.sessionId, ...state } },
1189
+ );
1190
+ }
1191
+
1192
+ try {
1193
+ await runtimeHooks.activateNativeBrowserApplication(input, product);
1194
+ } catch (error) {
1195
+ const causeCode = error instanceof AssistantPluginError ? error.code : undefined;
1196
+ const cause = error instanceof Error ? error.message : String(error);
1197
+ throw new AssistantPluginError(
1198
+ 'PLUGIN_BROWSER_ACTIVATION_FAILED',
1199
+ 'Native browser exact-tab activation could not establish macOS system foreground authority through Forge Desktop Operator.',
1200
+ { retryable: true, details: { sessionId: target.sessionId, browserProduct: product, ...state, ...(causeCode ? { causeCode } : {}), cause: cause.slice(0, 500) } },
1201
+ );
1202
+ }
1203
+
1204
+ // App-level activation is intentionally separate from tab selection. Re-assert the exact
1205
+ // saved tab after the app becomes frontmost, then verify both authorities together.
1206
+ await page.bringToFront();
1207
+ await delay(waitMs);
1208
+ state = await page.foregroundState();
1209
+ if (!state.frontmost || !state.active) {
1210
+ throw new AssistantPluginError(
1211
+ 'PLUGIN_BROWSER_ACTIVATION_FAILED',
1212
+ 'Native browser activation did not make the exact saved tab authoritative system foreground state.',
1213
+ { retryable: true, details: { sessionId: target.sessionId, browserProduct: product, ...state } },
1214
+ );
1215
+ }
1216
+ return state;
1217
+ }
1218
+
740
1219
  interface BrowserOpenHandle {
741
1220
  page: PageLike;
742
1221
  diagnostics: PageDiagnostics;
@@ -796,6 +1275,65 @@ function comparableUrl(value: string): string {
796
1275
  }
797
1276
  }
798
1277
 
1278
+ export function nativeReplacementUrlMatchesTarget(requestedUrl: string, actualUrl: string): boolean {
1279
+ if (comparableUrl(actualUrl) === comparableUrl(requestedUrl)) return true;
1280
+ try {
1281
+ const requested = new URL(requestedUrl);
1282
+ const actual = new URL(actualUrl);
1283
+ const requestedIsOriginRoot = (requested.protocol === 'https:' || requested.protocol === 'http:')
1284
+ && requested.pathname === '/'
1285
+ && requested.search === ''
1286
+ && requested.hash === '';
1287
+ return requestedIsOriginRoot && actual.origin === requested.origin;
1288
+ } catch {
1289
+ return false;
1290
+ }
1291
+ }
1292
+
1293
+ export async function settleNativeCreatedPageIdentity(
1294
+ page: {
1295
+ waitForLoadState: (state?: string, options?: Record<string, unknown>) => Promise<void>;
1296
+ identity: () => Promise<{ url: string; title: string }>;
1297
+ },
1298
+ state: string,
1299
+ timeoutMs: number,
1300
+ ): Promise<{ url: string; title: string }> {
1301
+ await page.waitForLoadState(state, { timeout: timeoutMs, requireHttpUrl: true });
1302
+ return await page.identity();
1303
+ }
1304
+
1305
+ export function nativeReplacementMismatchDiagnostic(input: {
1306
+ requestedUrl: string;
1307
+ actualUrl: string;
1308
+ previousUrl?: string;
1309
+ obsoleteRef?: MacOsBrowserTabRef;
1310
+ replacementRef?: MacOsBrowserTabRef;
1311
+ }): {
1312
+ sameTargetOrigin: boolean;
1313
+ samePreviousOrigin: boolean;
1314
+ samePreviousUrl: boolean;
1315
+ sameTabRef: boolean;
1316
+ actualScheme: string;
1317
+ } {
1318
+ let actualScheme = 'invalid';
1319
+ try {
1320
+ const protocol = new URL(input.actualUrl).protocol.replace(/:$/, '').toLowerCase();
1321
+ actualScheme = /^[a-z][a-z0-9+.-]*$/.test(protocol) ? protocol : 'invalid';
1322
+ } catch {
1323
+ // Keep the diagnostic bounded and non-sensitive when the observed URL is malformed.
1324
+ }
1325
+ const previousUrl = input.previousUrl?.trim();
1326
+ return {
1327
+ sameTargetOrigin: sameOrigin(input.actualUrl, input.requestedUrl),
1328
+ samePreviousOrigin: previousUrl ? sameOrigin(input.actualUrl, previousUrl) : false,
1329
+ samePreviousUrl: previousUrl ? comparableUrl(input.actualUrl) === comparableUrl(previousUrl) : false,
1330
+ sameTabRef: Boolean(input.obsoleteRef && input.replacementRef
1331
+ && input.obsoleteRef.windowId === input.replacementRef.windowId
1332
+ && input.obsoleteRef.tabId === input.replacementRef.tabId),
1333
+ actualScheme,
1334
+ };
1335
+ }
1336
+
799
1337
  function sameOrigin(left: string, right: string): boolean {
800
1338
  try {
801
1339
  return new URL(left).origin === new URL(right).origin;
@@ -804,6 +1342,105 @@ function sameOrigin(left: string, right: string): boolean {
804
1342
  }
805
1343
  }
806
1344
 
1345
+ function stableBrowserEntityKey(value: string): string | undefined {
1346
+ try {
1347
+ const url = new URL(value);
1348
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') return undefined;
1349
+ const segments = url.pathname.split('/').filter(Boolean);
1350
+ for (let index = 0; index + 1 < segments.length; index += 1) {
1351
+ if ((segments[index] ?? '').toLowerCase() !== 'apps') continue;
1352
+ const id = segments[index + 1]?.trim();
1353
+ if (id) return `${url.origin}/apps/${id}`;
1354
+ }
1355
+ return undefined;
1356
+ } catch {
1357
+ return undefined;
1358
+ }
1359
+ }
1360
+
1361
+ function selectReusableNativeTab(
1362
+ referenceUrls: string[],
1363
+ inventory: Awaited<ReturnType<typeof listMacOsBrowserTabs>>,
1364
+ preferredWindowId?: string,
1365
+ ): {
1366
+ candidate?: (typeof inventory.tabs)[number];
1367
+ match: 'exact_url' | 'stable_entity' | 'none' | 'ambiguous';
1368
+ candidateCount: number;
1369
+ } {
1370
+ const normalizedReferences = [...new Set(referenceUrls.filter(Boolean).map(comparableUrl))];
1371
+ const entityKeys = new Set(normalizedReferences.map(stableBrowserEntityKey).filter((value): value is string => Boolean(value)));
1372
+ const scored = inventory.tabs.map((candidate) => {
1373
+ const normalizedCandidate = comparableUrl(candidate.url);
1374
+ const entityKey = stableBrowserEntityKey(candidate.url);
1375
+ const matchScore = normalizedReferences.includes(normalizedCandidate)
1376
+ ? 100
1377
+ : entityKey && entityKeys.has(entityKey)
1378
+ ? 50
1379
+ : 0;
1380
+ const score = matchScore === 0
1381
+ ? 0
1382
+ : matchScore + (preferredWindowId && candidate.windowId === preferredWindowId ? 10 : 0) + (candidate.active ? 1 : 0);
1383
+ return { candidate, score, matchScore };
1384
+ });
1385
+ const maxScore = scored.reduce((max, entry) => Math.max(max, entry.score), 0);
1386
+ if (maxScore === 0) return { match: 'none', candidateCount: 0 };
1387
+ const matches = scored.filter((entry) => entry.score === maxScore);
1388
+ if (matches.length !== 1) return { match: 'ambiguous', candidateCount: matches.length };
1389
+ return { candidate: matches[0]!.candidate, match: matches[0]!.matchScore === 100 ? 'exact_url' : 'stable_entity', candidateCount: 1 };
1390
+ }
1391
+
1392
+ function selectLiveForgeOwnedNativeTab(
1393
+ repoRoot: string,
1394
+ targetUrl: string,
1395
+ product: MacOsBrowserProduct,
1396
+ inventory: Awaited<ReturnType<typeof listMacOsBrowserTabs>>,
1397
+ ): {
1398
+ session?: BrowserSessionState;
1399
+ candidate?: (typeof inventory.tabs)[number];
1400
+ match: 'exact_url' | 'same_origin' | 'none' | 'ambiguous';
1401
+ candidateCount: number;
1402
+ } {
1403
+ const liveByRef = new Map(inventory.tabs.map((tab) => [`${tab.windowId}:${tab.tabId}`, tab]));
1404
+ const candidates = new Map<string, { session: BrowserSessionState; candidate: (typeof inventory.tabs)[number] }>();
1405
+ for (const session of listSavedBrowserSessions(repoRoot)) {
1406
+ const browser = session.browser;
1407
+ const tab = browser?.tab;
1408
+ if (browser?.provider !== 'macos-apple-events' || browser.browserProduct !== product || tab?.ownership !== 'plugin_owned') continue;
1409
+ if (!tab.windowId || !tab.tabId) continue;
1410
+ const key = `${tab.windowId}:${tab.tabId}`;
1411
+ const live = liveByRef.get(key);
1412
+ if (!live) continue;
1413
+ const previous = candidates.get(key);
1414
+ if (!previous || session.updatedAt > previous.session.updatedAt) candidates.set(key, { session, candidate: live });
1415
+ }
1416
+
1417
+ const values = [...candidates.values()];
1418
+ const exact = values.filter(({ candidate }) => comparableUrl(candidate.url) === comparableUrl(targetUrl));
1419
+ const choose = (matches: typeof values, match: 'exact_url' | 'same_origin') => {
1420
+ if (matches.length === 1) return { ...matches[0], match, candidateCount: 1 } as const;
1421
+ const active = matches.filter(({ candidate }) => candidate.active);
1422
+ if (active.length === 1) return { ...active[0], match, candidateCount: matches.length } as const;
1423
+ return { match: 'ambiguous' as const, candidateCount: matches.length };
1424
+ };
1425
+ if (exact.length > 0) return choose(exact, 'exact_url');
1426
+
1427
+ const sameOriginCandidates = values.filter(({ candidate }) => sameOrigin(candidate.url, targetUrl));
1428
+ if (sameOriginCandidates.length > 0) return choose(sameOriginCandidates, 'same_origin');
1429
+ return { match: 'none', candidateCount: 0 };
1430
+ }
1431
+
1432
+ function selectExactNativeTab(
1433
+ targetUrl: string,
1434
+ inventory: Awaited<ReturnType<typeof listMacOsBrowserTabs>>,
1435
+ ): { candidate?: (typeof inventory.tabs)[number]; match: 'exact_url' | 'none' | 'ambiguous'; candidateCount: number } {
1436
+ const matches = inventory.tabs.filter((candidate) => comparableUrl(candidate.url) === comparableUrl(targetUrl));
1437
+ if (matches.length === 0) return { match: 'none', candidateCount: 0 };
1438
+ if (matches.length === 1) return { candidate: matches[0], match: 'exact_url', candidateCount: 1 };
1439
+ const active = matches.filter((candidate) => candidate.active);
1440
+ if (active.length === 1) return { candidate: active[0], match: 'exact_url', candidateCount: matches.length };
1441
+ return { match: 'ambiguous', candidateCount: matches.length };
1442
+ }
1443
+
807
1444
  function isBlankPage(url: string): boolean {
808
1445
  return !url || url === 'about:blank' || url === 'chrome://newtab/';
809
1446
  }
@@ -980,6 +1617,7 @@ function sessionConnectionFromSummary(connection: BrowserConnectionSummary): Bro
980
1617
  endpoint: connection.endpoint,
981
1618
  browserVersion: connection.browserVersion,
982
1619
  browserProduct: connection.browserProduct,
1620
+ nativeBrowserCandidates: connection.nativeBrowserCandidates,
983
1621
  fallback: connection.fallback,
984
1622
  tab: connection.tab,
985
1623
  sessionResume: connection.sessionResume,
@@ -1045,6 +1683,230 @@ function launchOptionsForRepo(repoRoot: string, config: BrowserPluginConfig, pro
1045
1683
  };
1046
1684
  }
1047
1685
 
1686
+ function managedContextKey(profile: BrowserProfileSelection): string {
1687
+ return resolve(profile.selectedProfilePath);
1688
+ }
1689
+
1690
+ async function evictManagedContext(key: string): Promise<void> {
1691
+ const pending = managedBrowserContexts.get(key);
1692
+ managedBrowserContexts.delete(key);
1693
+ if (!pending) return;
1694
+ try {
1695
+ const state = await pending;
1696
+ await state.context.close().catch(() => undefined);
1697
+ } catch {
1698
+ // Failed launches are already unusable; removing the cache entry is enough.
1699
+ }
1700
+ }
1701
+
1702
+ async function closeManagedContextsForRepo(repoRoot: string, options: { strict?: boolean } = {}): Promise<void> {
1703
+ const canonicalRoot = resolve(repoRoot);
1704
+ for (const [key, pending] of [...managedBrowserContexts.entries()]) {
1705
+ let state: ManagedBrowserContextState;
1706
+ try {
1707
+ state = await pending;
1708
+ } catch {
1709
+ managedBrowserContexts.delete(key);
1710
+ continue;
1711
+ }
1712
+ if (state.repoRoot !== canonicalRoot) continue;
1713
+ try {
1714
+ await state.context.close();
1715
+ managedBrowserContexts.delete(key);
1716
+ } catch (error) {
1717
+ if (options.strict === true) {
1718
+ throw new AssistantPluginError('PLUGIN_BROWSER_MANAGED_CONTEXT_CLOSE_FAILED', 'Managed browser context could not be closed; retaining Runtime binding and session metadata for a safe retry.', {
1719
+ retryable: true, details: { profilePath: state.profilePath, cause: error instanceof Error ? error.message : String(error) },
1720
+ });
1721
+ }
1722
+ managedBrowserContexts.delete(key);
1723
+ }
1724
+ }
1725
+ }
1726
+
1727
+ async function managedContextState(
1728
+ runtime: PlaywrightRuntime,
1729
+ repoRoot: string,
1730
+ config: BrowserPluginConfig,
1731
+ profile: BrowserProfileSelection,
1732
+ ): Promise<ManagedBrowserContextState> {
1733
+ const key = managedContextKey(profile);
1734
+ const existing = managedBrowserContexts.get(key);
1735
+ if (existing) {
1736
+ try {
1737
+ const state = await existing;
1738
+ state.context.pages();
1739
+ return state;
1740
+ } catch {
1741
+ managedBrowserContexts.delete(key);
1742
+ }
1743
+ }
1744
+ const launch = (async () => {
1745
+ const context = await runtime.chromium.launchPersistentContext(profile.profileDir, launchOptionsForRepo(repoRoot, config, profile));
1746
+ return {
1747
+ repoRoot: resolve(repoRoot),
1748
+ profilePath: key,
1749
+ context,
1750
+ pagesBySession: new Map<string, PageLike>(),
1751
+ } satisfies ManagedBrowserContextState;
1752
+ })();
1753
+ managedBrowserContexts.set(key, launch);
1754
+ try {
1755
+ return await launch;
1756
+ } catch (error) {
1757
+ if (managedBrowserContexts.get(key) === launch) managedBrowserContexts.delete(key);
1758
+ throw error;
1759
+ }
1760
+ }
1761
+
1762
+ async function markManagedPageOwner(page: PageLike, ownerToken: string): Promise<void> {
1763
+ await page.evaluate((_token) => {
1764
+ if (typeof window !== 'undefined') window.name = String(_token ?? '');
1765
+ }, ownerToken).catch(() => undefined);
1766
+ }
1767
+
1768
+ async function selectManagedSessionPage(
1769
+ state: ManagedBrowserContextState,
1770
+ target: BrowserActionTarget,
1771
+ options: { allowReplacement?: boolean } = {},
1772
+ ) {
1773
+ const pages = state.context.pages();
1774
+ const inventory = await inventoryTabs(state.context);
1775
+ const ownerToken = ownerTokenForSession(target.sessionId);
1776
+ const mapped = state.pagesBySession.get(target.sessionId);
1777
+ if (mapped && pages.includes(mapped)) {
1778
+ const index = pages.indexOf(mapped);
1779
+ await markManagedPageOwner(mapped, ownerToken);
1780
+ return {
1781
+ page: mapped,
1782
+ matchedBy: 'owned_token' as const,
1783
+ inventory,
1784
+ selectedEntry: inventory[index],
1785
+ sessionResume: {
1786
+ sessionId: target.sessionId,
1787
+ status: 'matched' as const,
1788
+ reason: 'Reused the live managed page bound to this browser session.',
1789
+ savedTab: target.existingSession?.browser?.tab,
1790
+ },
1791
+ ownerToken,
1792
+ };
1793
+ }
1794
+ state.pagesBySession.delete(target.sessionId);
1795
+
1796
+ const ownerIndex = inventory.findIndex((entry) => entry.ownerToken === ownerToken);
1797
+ if (ownerIndex >= 0) {
1798
+ const page = pages[ownerIndex];
1799
+ if (page) {
1800
+ state.pagesBySession.set(target.sessionId, page);
1801
+ return {
1802
+ page,
1803
+ matchedBy: 'owned_token' as const,
1804
+ inventory,
1805
+ selectedEntry: inventory[ownerIndex],
1806
+ sessionResume: {
1807
+ sessionId: target.sessionId,
1808
+ status: 'matched' as const,
1809
+ reason: 'Rediscovered the managed page from its stable session owner marker.',
1810
+ savedTab: target.existingSession?.browser?.tab,
1811
+ },
1812
+ ownerToken,
1813
+ };
1814
+ }
1815
+ }
1816
+
1817
+ if (target.existingSession && options.allowReplacement === false) {
1818
+ throw new AssistantPluginError(
1819
+ 'PLUGIN_BROWSER_SESSION_STATE_LOST',
1820
+ 'Saved managed browser page no longer exists; refusing to create or claim a replacement page for this existing-session action.',
1821
+ {
1822
+ retryable: false,
1823
+ details: { sessionId: target.sessionId, provider: 'playwright-persistent-context' },
1824
+ },
1825
+ );
1826
+ }
1827
+
1828
+ const blankIndex = inventory.findIndex((entry) => isBlankPage(entry.url) && !entry.ownerToken);
1829
+ const page = blankIndex >= 0 && pages[blankIndex] ? pages[blankIndex]! : await state.context.newPage();
1830
+ const matchedBy: BrowserTabMatchReason = blankIndex >= 0 ? 'blank' : 'new_page';
1831
+ await markManagedPageOwner(page, ownerToken);
1832
+ state.pagesBySession.set(target.sessionId, page);
1833
+ return {
1834
+ page,
1835
+ matchedBy,
1836
+ inventory,
1837
+ selectedEntry: blankIndex >= 0 ? inventory[blankIndex] : undefined,
1838
+ sessionResume: target.existingSession?.browser?.tab
1839
+ ? {
1840
+ sessionId: target.sessionId,
1841
+ status: 'stale_tab' as const,
1842
+ reason: 'The previously owned managed page no longer exists; created one replacement page for this session only.',
1843
+ savedTab: target.existingSession.browser.tab,
1844
+ }
1845
+ : {
1846
+ sessionId: target.sessionId,
1847
+ status: 'no_saved_tab' as const,
1848
+ reason: matchedBy === 'blank'
1849
+ ? 'Claimed one unowned blank page for this managed browser session.'
1850
+ : 'Created one dedicated managed page for this browser session.',
1851
+ },
1852
+ ownerToken,
1853
+ };
1854
+ }
1855
+
1856
+ async function runtimeManagedPageForSession(
1857
+ repoRoot: string,
1858
+ config: BrowserPluginConfig,
1859
+ session: BrowserSessionState,
1860
+ ): Promise<{ page: PageLike; profile: BrowserProfileSelection } | undefined> {
1861
+ if (session.browser?.provider !== 'playwright-persistent-context' || session.browser.activeMode !== 'managed_persistent') return undefined;
1862
+ const profile = selectedProfile(config, repoRoot, 'managed_persistent', session.sessionId);
1863
+ const pending = managedBrowserContexts.get(managedContextKey(profile));
1864
+ if (!pending) return undefined;
1865
+ let state: ManagedBrowserContextState;
1866
+ try {
1867
+ state = await pending;
1868
+ } catch {
1869
+ return undefined;
1870
+ }
1871
+ let page = state.pagesBySession.get(session.sessionId);
1872
+ if (page && state.context.pages().includes(page)) return { page, profile };
1873
+ state.pagesBySession.delete(session.sessionId);
1874
+ const expectedOwnerToken = session.browser.tab?.ownerToken ?? ownerTokenForSession(session.sessionId);
1875
+ const inventory = await inventoryTabs(state.context).catch(() => [] as BrowserTabInventoryEntry[]);
1876
+ const index = inventory.findIndex((entry) => entry.ownerToken === expectedOwnerToken);
1877
+ page = index >= 0 ? state.context.pages()[index] : undefined;
1878
+ if (!page) return undefined;
1879
+ state.pagesBySession.set(session.sessionId, page);
1880
+ return { page, profile };
1881
+ }
1882
+
1883
+ async function closeManagedSessionPage(repoRoot: string, config: BrowserPluginConfig, session: BrowserSessionState): Promise<boolean> {
1884
+ if (session.browser?.provider !== 'playwright-persistent-context' || session.browser.activeMode !== 'managed_persistent') return false;
1885
+ const profile = selectedProfile(config, repoRoot, 'managed_persistent', session.sessionId);
1886
+ const pending = managedBrowserContexts.get(managedContextKey(profile));
1887
+ if (!pending) return false;
1888
+ let state: ManagedBrowserContextState;
1889
+ try {
1890
+ state = await pending;
1891
+ } catch {
1892
+ return false;
1893
+ }
1894
+ let page = state.pagesBySession.get(session.sessionId);
1895
+ if (!page) {
1896
+ const expectedOwnerToken = session.browser.tab?.ownerToken ?? ownerTokenForSession(session.sessionId);
1897
+ const inventory = await inventoryTabs(state.context).catch(() => [] as BrowserTabInventoryEntry[]);
1898
+ const index = inventory.findIndex((entry) => entry.ownerToken === expectedOwnerToken);
1899
+ if (index >= 0) page = state.context.pages()[index];
1900
+ }
1901
+ if (!page || typeof page.close !== 'function') {
1902
+ state.pagesBySession.delete(session.sessionId);
1903
+ return false;
1904
+ }
1905
+ await page.close();
1906
+ state.pagesBySession.delete(session.sessionId);
1907
+ return true;
1908
+ }
1909
+
1048
1910
  async function openManagedContext(
1049
1911
  runtime: PlaywrightRuntime,
1050
1912
  repoRoot: string,
@@ -1056,15 +1918,67 @@ async function openManagedContext(
1056
1918
  fallback?: BrowserConnectionFallback,
1057
1919
  ): Promise<BrowserOpenHandle> {
1058
1920
  const profile = selectedProfile(config, repoRoot, activeMode, target.sessionId);
1921
+ const persistentKey = managedContextKey(profile);
1922
+ const requireExistingResource = args.__forge_require_existing_resource === true;
1923
+ const allowManagedSessionRehydrate = requireExistingResource && args.__forge_allow_managed_session_rehydrate === true;
1924
+ const managedContextWasMissing = !managedBrowserContexts.has(persistentKey);
1925
+ if (requireExistingResource && target.existingSession && activeMode === 'managed_persistent') {
1926
+ const savedConnection = target.existingSession.browser;
1927
+ if (savedConnection?.provider !== 'playwright-persistent-context'
1928
+ || savedConnection.activeMode !== 'managed_persistent') {
1929
+ throw new AssistantPluginError(
1930
+ 'PLUGIN_BROWSER_SESSION_STATE_LOST',
1931
+ 'Saved browser session belongs to a different browser provider/mode; refusing to migrate it into a replacement managed browser/page for this existing-session action.',
1932
+ {
1933
+ retryable: false,
1934
+ details: {
1935
+ sessionId: target.sessionId,
1936
+ savedProvider: savedConnection?.provider,
1937
+ savedActiveMode: savedConnection?.activeMode,
1938
+ requestedActiveMode: activeMode,
1939
+ },
1940
+ },
1941
+ );
1942
+ }
1943
+ if (managedContextWasMissing && !allowManagedSessionRehydrate) {
1944
+ throw new AssistantPluginError(
1945
+ 'PLUGIN_BROWSER_SESSION_STATE_LOST',
1946
+ 'The managed browser context for this saved session is no longer live; refusing to launch a replacement browser/page for this existing-session action.',
1947
+ { retryable: false, details: { sessionId: target.sessionId, provider: 'playwright-persistent-context' } },
1948
+ );
1949
+ }
1950
+ }
1059
1951
  assertBrowserProfileAvailable(repoRoot, profile.selectedProfilePath);
1060
1952
  mkdirSync(profile.profileDir, { recursive: true });
1061
- const context = await runtime.chromium.launchPersistentContext(profile.profileDir, launchOptionsForRepo(repoRoot, config, profile));
1062
- const selected = await selectPage(context, target);
1953
+ let context: BrowserContextLike;
1954
+ let selected: Awaited<ReturnType<typeof selectManagedSessionPage>> | Awaited<ReturnType<typeof selectPage>>;
1955
+ let managedState: ManagedBrowserContextState | undefined;
1956
+ if (activeMode === 'managed_persistent') {
1957
+ managedState = await managedContextState(runtime, repoRoot, config, profile);
1958
+ context = managedState.context;
1959
+ try {
1960
+ selected = await selectManagedSessionPage(managedState, target, { allowReplacement: !requireExistingResource });
1961
+ } catch (error) {
1962
+ if (requireExistingResource) {
1963
+ if (allowManagedSessionRehydrate && managedContextWasMissing) await evictManagedContext(persistentKey);
1964
+ throw error;
1965
+ }
1966
+ await evictManagedContext(persistentKey);
1967
+ assertBrowserProfileAvailable(repoRoot, profile.selectedProfilePath);
1968
+ managedState = await managedContextState(runtime, repoRoot, config, profile);
1969
+ context = managedState.context;
1970
+ selected = await selectManagedSessionPage(managedState, target);
1971
+ }
1972
+ } else {
1973
+ context = await runtime.chromium.launchPersistentContext(profile.profileDir, launchOptionsForRepo(repoRoot, config, profile));
1974
+ selected = await selectPage(context, target);
1975
+ }
1063
1976
  const timeout = positiveNumber(args.timeout_ms, config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS);
1064
1977
  let response: { status?: () => number } | null | undefined;
1065
1978
  if (selected.matchedBy === 'new_page' || selected.matchedBy === 'blank' || comparableUrl(selected.page.url()) !== comparableUrl(target.url)) {
1066
1979
  response = await selected.page.goto(target.url, { waitUntil: waitUntil(args.wait_until), timeout }) as { status?: () => number } | null | undefined;
1067
1980
  }
1981
+ if (managedState && 'ownerToken' in selected) await markManagedPageOwner(selected.page, selected.ownerToken);
1068
1982
  const diagnostics = attachDiagnostics(selected.page);
1069
1983
  diagnostics.navigation = {
1070
1984
  url: target.url,
@@ -1084,13 +1998,16 @@ async function openManagedContext(
1084
1998
  },
1085
1999
  tabInventory: selected.inventory,
1086
2000
  sessionResume: selected.sessionResume,
1087
- }, normalizedUrl(selected.page.url()), title, selected.matchedBy);
2001
+ }, normalizedUrl(selected.page.url()), title, selected.matchedBy,
2002
+ managedState && 'ownerToken' in selected
2003
+ ? { ownership: 'plugin_owned', ownerToken: selected.ownerToken }
2004
+ : undefined);
1088
2005
  return {
1089
2006
  page: selected.page,
1090
2007
  diagnostics,
1091
2008
  connection,
1092
2009
  close: async () => {
1093
- await context.close().catch(() => undefined);
2010
+ if (activeMode === 'isolated') await context.close().catch(() => undefined);
1094
2011
  },
1095
2012
  };
1096
2013
  }
@@ -1177,6 +2094,11 @@ async function openAttachedContext(
1177
2094
  return { attempts };
1178
2095
  }
1179
2096
 
2097
+ function nativeBackgroundNavigationRequiresReplacement(error: unknown): boolean {
2098
+ const message = error instanceof Error ? error.message : String(error);
2099
+ return message.includes('BROWSER_AUTOMATION_BACKGROUND_NAVIGATION_REQUIRES_REPLACEMENT');
2100
+ }
2101
+
1180
2102
  async function openNativeAttachedContext(
1181
2103
  repoRoot: string,
1182
2104
  config: BrowserPluginConfig,
@@ -1184,17 +2106,46 @@ async function openNativeAttachedContext(
1184
2106
  args: Record<string, unknown>,
1185
2107
  ): Promise<{ handle?: BrowserOpenHandle; attempts: MacOsBrowserAttachAttempt[] }> {
1186
2108
  if (config.nativeAttachMode === 'disabled') return { attempts: [] };
2109
+ if (!target.existingSession && !stringValue(args.session_id)) {
2110
+ await inspectNativeOwnedSessions(repoRoot, config, listSavedSessions(repoRoot), { pruneDead: true });
2111
+ }
1187
2112
  const timeout = positiveNumber(args.timeout_ms, config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS);
1188
2113
  const savedTab = target.existingSession?.browser?.tab;
2114
+ const savedOwnership = savedTab?.ownership;
2115
+ let effectiveOwnership = savedOwnership;
2116
+ let recoveredTabTitle = '';
2117
+ let recoveredTabUrl: string | undefined;
1189
2118
  const savedProduct = target.existingSession?.browser?.provider === 'macos-apple-events'
1190
- && savedTab?.ownership === 'plugin_owned'
2119
+ && savedTab
2120
+ && (savedOwnership === 'plugin_owned' || savedOwnership === 'user_owned')
1191
2121
  && typeof savedTab.windowId === 'string'
1192
2122
  && typeof savedTab.tabId === 'string'
1193
2123
  ? target.existingSession.browser.browserProduct
1194
2124
  : undefined;
2125
+ if (args.__forge_require_existing_resource === true
2126
+ && target.existingSession?.browser?.provider === 'macos-apple-events'
2127
+ && !savedProduct) {
2128
+ throw new AssistantPluginError(
2129
+ 'PLUGIN_BROWSER_SESSION_STATE_LOST',
2130
+ 'Saved native browser session lacks a complete stable browser/tab identity; refusing to create or adopt a replacement tab for this existing-session action.',
2131
+ {
2132
+ retryable: false,
2133
+ details: {
2134
+ sessionId: target.sessionId,
2135
+ provider: target.existingSession.browser.provider,
2136
+ browserProduct: target.existingSession.browser.browserProduct,
2137
+ ownership: savedOwnership,
2138
+ windowId: savedTab?.windowId,
2139
+ tabId: savedTab?.tabId,
2140
+ },
2141
+ },
2142
+ );
2143
+ }
1195
2144
  let page: PageLike | undefined;
1196
2145
  let discovered: Awaited<ReturnType<typeof discoverMacOsBrowserAttachment>> | undefined;
1197
- let sessionResume: BrowserSessionResumeDiagnostic;
2146
+ let sessionResume: BrowserSessionResumeDiagnostic = savedTab
2147
+ ? { sessionId: target.sessionId, status: 'stale_tab', reason: 'Saved native tab has not yet been reattached.', savedTab }
2148
+ : { sessionId: target.sessionId, status: 'no_saved_tab', reason: 'No saved native tab exists yet.' };
1198
2149
  let matchedBy: BrowserTabMatchReason = 'new_page';
1199
2150
 
1200
2151
  if (savedProduct && savedTab && typeof savedTab.windowId === 'string' && typeof savedTab.tabId === 'string') {
@@ -1203,10 +2154,71 @@ async function openNativeAttachedContext(
1203
2154
  const reattached = await reattachMacOsBrowserOwnedPage(savedProduct, ref, timeout);
1204
2155
  page = reattached.page as unknown as PageLike;
1205
2156
  discovered = { attachment: reattached.attachment, attempts: reattached.attachment.attempts };
1206
- matchedBy = 'owned_token';
1207
- sessionResume = { sessionId: target.sessionId, status: 'matched', reason: 'Reattached directly to the saved plugin-owned macOS browser tab.', savedTab };
1208
- } catch {
1209
- sessionResume = { sessionId: target.sessionId, status: 'stale_tab', reason: 'Saved plugin-owned macOS tab no longer exists; one replacement tab was created.', savedTab };
2157
+ matchedBy = savedOwnership === 'user_owned' ? 'saved_url' : 'owned_token';
2158
+ sessionResume = {
2159
+ sessionId: target.sessionId,
2160
+ status: 'matched',
2161
+ reason: savedOwnership === 'user_owned'
2162
+ ? 'Reattached directly to the explicitly adopted user-owned macOS browser tab.'
2163
+ : 'Reattached directly to the saved plugin-owned macOS browser tab.',
2164
+ savedTab,
2165
+ };
2166
+ } catch (error) {
2167
+ if (error instanceof AssistantPluginError && error.code === 'PLUGIN_BROWSER_NATIVE_TAB_IDENTITY_UNPROVEN') {
2168
+ throw error;
2169
+ }
2170
+ if (savedOwnership === 'user_owned') {
2171
+ throw new AssistantPluginError(
2172
+ 'PLUGIN_BROWSER_SESSION_STATE_LOST',
2173
+ 'Explicitly adopted user-owned macOS browser tab no longer exists; refusing to silently switch to another user tab.',
2174
+ {
2175
+ retryable: false,
2176
+ details: { sessionId: target.sessionId, browserProduct: savedProduct, ownership: savedOwnership, windowId: savedTab.windowId, tabId: savedTab.tabId, cause: error instanceof Error ? error.message : String(error) },
2177
+ },
2178
+ );
2179
+ }
2180
+ let inventory: Awaited<ReturnType<typeof listMacOsBrowserTabs>> | undefined;
2181
+ let selection: ReturnType<typeof selectReusableNativeTab> | undefined;
2182
+ let recoveryFailure: string | undefined;
2183
+ try {
2184
+ inventory = await listMacOsBrowserTabs(savedProduct, timeout);
2185
+ selection = selectReusableNativeTab(
2186
+ [savedTab.url, target.existingSession?.url ?? '', target.url],
2187
+ inventory,
2188
+ savedTab.windowId,
2189
+ );
2190
+ if (selection.candidate) {
2191
+ const recovered = await reattachMacOsBrowserOwnedPage(savedProduct, { windowId: selection.candidate.windowId, tabId: selection.candidate.tabId }, timeout);
2192
+ page = recovered.page as unknown as PageLike;
2193
+ discovered = { attachment: recovered.attachment, attempts: recovered.attachment.attempts };
2194
+ matchedBy = 'recovered_tab';
2195
+ effectiveOwnership = 'user_owned';
2196
+ recoveredTabTitle = selection.candidate.title;
2197
+ recoveredTabUrl = selection.candidate.url;
2198
+ sessionResume = {
2199
+ sessionId: target.sessionId,
2200
+ status: 'matched',
2201
+ reason: selection.match === 'exact_url'
2202
+ ? 'Saved plugin-owned tab disappeared; rebound safely to the unique currently open tab with the same URL and preserved it as user-owned.'
2203
+ : 'Saved plugin-owned tab disappeared; rebound safely to the unique currently open tab for the same stable app entity and preserved its current page as user-owned.',
2204
+ savedTab,
2205
+ };
2206
+ }
2207
+ } catch (recoveryError) {
2208
+ recoveryFailure = recoveryError instanceof Error ? recoveryError.message : String(recoveryError);
2209
+ }
2210
+ if (!page) {
2211
+ sessionResume = {
2212
+ sessionId: target.sessionId,
2213
+ status: 'stale_tab',
2214
+ reason: recoveryFailure
2215
+ ? `Saved plugin-owned macOS browser tab disappeared and current-tab recovery failed (${recoveryFailure}); creating one replacement plugin-owned tab.`
2216
+ : selection?.match === 'ambiguous'
2217
+ ? 'Saved plugin-owned macOS browser tab disappeared and multiple reusable current tabs matched; refusing to guess between user tabs and creating one replacement plugin-owned tab.'
2218
+ : 'Saved plugin-owned macOS browser tab disappeared and no uniquely reusable current tab matched; creating one replacement plugin-owned tab.',
2219
+ savedTab,
2220
+ };
2221
+ }
1210
2222
  }
1211
2223
  } else {
1212
2224
  sessionResume = savedTab
@@ -1215,52 +2227,219 @@ async function openNativeAttachedContext(
1215
2227
  }
1216
2228
 
1217
2229
  if (!discovered?.attachment) {
1218
- const candidates = savedProduct ? [savedProduct, ...(config.nativeBrowserCandidates ?? ['vivaldi', 'chrome']).filter((product) => product !== savedProduct)] : (config.nativeBrowserCandidates ?? ['vivaldi', 'chrome']);
2230
+ const candidates = savedProduct ? [savedProduct] : (config.nativeBrowserCandidates ?? ['vivaldi', 'chrome']);
1219
2231
  discovered = await discoverMacOsBrowserAttachment(candidates, Math.min(timeout, MAX_CDP_DISCOVERY_TIMEOUT_MS));
1220
2232
  }
1221
2233
  if (!discovered.attachment) return { attempts: discovered.attempts };
2234
+ let activeAttachment = discovered.attachment;
1222
2235
 
1223
2236
  let createdThisCallRef: MacOsBrowserTabRef | undefined;
1224
- let replacedRef: MacOsBrowserTabRef | undefined;
2237
+ let navigationUsedReplacement = false;
2238
+ const navigateNativePageWithReplacement = async (candidate: PageLike): Promise<PageLike> => {
2239
+ const obsoleteRef = (candidate as unknown as { tabRef?: () => MacOsBrowserTabRef | undefined }).tabRef?.();
2240
+ try {
2241
+ await candidate.goto(target.url, { waitUntil: waitUntil(args.wait_until), timeout });
2242
+ return candidate;
2243
+ } catch (error) {
2244
+ if (!nativeBackgroundNavigationRequiresReplacement(error) || effectiveOwnership === 'user_owned' || !obsoleteRef) throw error;
2245
+ const replacement = await createMacOsBrowserOwnedPage(activeAttachment, target.url, timeout) as unknown as PageLike;
2246
+ const replacementRef = (replacement as unknown as { tabRef?: () => MacOsBrowserTabRef | undefined }).tabRef?.();
2247
+ if (!replacementRef) {
2248
+ throw new AssistantPluginError('PLUGIN_BROWSER_NATIVE_OWNERSHIP_MISSING', 'Native replacement tab did not return a stable plugin-owned tab reference.', { retryable: true });
2249
+ }
2250
+ try {
2251
+ const identity = await settleNativeCreatedPageIdentity(
2252
+ replacement as unknown as {
2253
+ waitForLoadState: (state?: string, options?: Record<string, unknown>) => Promise<void>;
2254
+ identity: () => Promise<{ url: string; title: string }>;
2255
+ },
2256
+ waitUntil(args.wait_until),
2257
+ timeout,
2258
+ );
2259
+ if (!nativeReplacementUrlMatchesTarget(target.url, identity.url)) {
2260
+ const diagnostic = nativeReplacementMismatchDiagnostic({
2261
+ requestedUrl: target.url,
2262
+ actualUrl: identity.url,
2263
+ previousUrl: target.existingSession?.url ?? candidate.url(),
2264
+ obsoleteRef,
2265
+ replacementRef,
2266
+ });
2267
+ const diagnosticText = Object.entries(diagnostic)
2268
+ .map(([key, value]) => `${key}=${String(value)}`)
2269
+ .join(',');
2270
+ throw new AssistantPluginError(
2271
+ 'PLUGIN_BROWSER_NATIVE_REPLACEMENT_MISMATCH',
2272
+ `Native replacement tab did not reach the requested URL; preserving the original plugin-owned tab. diagnostic=${diagnosticText}`,
2273
+ { retryable: true, details: diagnostic },
2274
+ );
2275
+ }
2276
+ } catch (replacementError) {
2277
+ await closeMacOsBrowserOwnedTab(activeAttachment.metadata.product, replacementRef, timeout).catch(() => undefined);
2278
+ throw replacementError;
2279
+ }
2280
+ try {
2281
+ await closeMacOsBrowserOwnedTab(activeAttachment.metadata.product, obsoleteRef, timeout);
2282
+ } catch (closeError) {
2283
+ await closeMacOsBrowserOwnedTab(activeAttachment.metadata.product, replacementRef, timeout).catch(() => undefined);
2284
+ throw closeError;
2285
+ }
2286
+ createdThisCallRef = replacementRef;
2287
+ navigationUsedReplacement = true;
2288
+ matchedBy = 'owned_token';
2289
+ effectiveOwnership = 'plugin_owned';
2290
+ sessionResume = {
2291
+ sessionId: target.sessionId,
2292
+ status: 'matched',
2293
+ reason: 'Browser broker required replacement for background cross-URL navigation; created and verified a replacement plugin-owned tab before closing the obsolete tab.',
2294
+ savedTab,
2295
+ };
2296
+ return replacement;
2297
+ }
2298
+ };
1225
2299
  try {
2300
+ if (!page && !savedTab && !stringValue(args.session_id)) {
2301
+ let inventory: Awaited<ReturnType<typeof listMacOsBrowserTabs>> | undefined;
2302
+ try {
2303
+ inventory = await listMacOsBrowserTabs(activeAttachment.metadata.product, timeout);
2304
+ } catch (error) {
2305
+ if (!nativeTabInventoryUnsupported(error)) throw error;
2306
+ }
2307
+ if (inventory) {
2308
+ const ownedSelection = selectLiveForgeOwnedNativeTab(repoRoot, target.url, activeAttachment.metadata.product, inventory);
2309
+ if (ownedSelection.match === 'ambiguous') {
2310
+ throw new AssistantPluginError(
2311
+ 'PLUGIN_BROWSER_REUSABLE_TAB_AMBIGUOUS',
2312
+ 'Multiple live Forge-owned browser tabs can satisfy this sessionless navigation; refusing to open another duplicate tab.',
2313
+ { retryable: false, details: { targetUrl: target.url, candidateCount: ownedSelection.candidateCount } },
2314
+ );
2315
+ }
2316
+ if (ownedSelection.candidate) {
2317
+ if (ownedSelection.session) {
2318
+ target.sessionId = ownedSelection.session.sessionId;
2319
+ target.existingSession = ownedSelection.session;
2320
+ }
2321
+ const rebound = await reattachMacOsBrowserOwnedPage(
2322
+ activeAttachment.metadata.product,
2323
+ { windowId: ownedSelection.candidate.windowId, tabId: ownedSelection.candidate.tabId },
2324
+ timeout,
2325
+ );
2326
+ page = rebound.page as unknown as PageLike;
2327
+ discovered = { attachment: rebound.attachment, attempts: rebound.attachment.attempts };
2328
+ activeAttachment = rebound.attachment;
2329
+ matchedBy = 'owned_token';
2330
+ effectiveOwnership = 'plugin_owned';
2331
+ sessionResume = {
2332
+ sessionId: target.sessionId,
2333
+ status: 'matched',
2334
+ reason: ownedSelection.match === 'exact_url'
2335
+ ? 'No session id was supplied; reused the unique live Forge-owned native tab already at the target URL.'
2336
+ : 'No session id was supplied; reused the unique live Forge-owned native tab for the same origin and navigated it in place.',
2337
+ savedTab: ownedSelection.session?.browser?.tab,
2338
+ };
2339
+ } else {
2340
+ const exactSelection = selectExactNativeTab(target.url, inventory);
2341
+ if (exactSelection.match === 'ambiguous') {
2342
+ throw new AssistantPluginError(
2343
+ 'PLUGIN_BROWSER_REUSABLE_TAB_AMBIGUOUS',
2344
+ 'Multiple existing browser tabs already match the target URL; refusing to open another duplicate tab.',
2345
+ { retryable: false, details: { targetUrl: target.url, candidateCount: exactSelection.candidateCount } },
2346
+ );
2347
+ }
2348
+ if (exactSelection.candidate) {
2349
+ const rebound = await reattachMacOsBrowserOwnedPage(
2350
+ activeAttachment.metadata.product,
2351
+ { windowId: exactSelection.candidate.windowId, tabId: exactSelection.candidate.tabId },
2352
+ timeout,
2353
+ );
2354
+ page = rebound.page as unknown as PageLike;
2355
+ discovered = { attachment: rebound.attachment, attempts: rebound.attachment.attempts };
2356
+ activeAttachment = rebound.attachment;
2357
+ matchedBy = 'exact_url';
2358
+ effectiveOwnership = 'user_owned';
2359
+ recoveredTabTitle = exactSelection.candidate.title;
2360
+ recoveredTabUrl = exactSelection.candidate.url;
2361
+ sessionResume = {
2362
+ sessionId: target.sessionId,
2363
+ status: 'matched',
2364
+ reason: 'No session id was supplied; reused the unique existing native browser tab with the exact target URL instead of opening a duplicate.',
2365
+ };
2366
+ }
2367
+ }
2368
+ }
2369
+ }
1226
2370
  if (!page) {
1227
- page = await createMacOsBrowserOwnedPage(discovered.attachment, target.url, timeout) as unknown as PageLike;
2371
+ page = await createMacOsBrowserOwnedPage(activeAttachment, target.url, timeout) as unknown as PageLike;
1228
2372
  createdThisCallRef = (page as unknown as { tabRef?: () => MacOsBrowserTabRef | undefined }).tabRef?.();
2373
+ const liveIdentity = await settleNativeCreatedPageIdentity(
2374
+ page as unknown as {
2375
+ waitForLoadState: (state?: string, options?: Record<string, unknown>) => Promise<void>;
2376
+ identity: () => Promise<{ url: string; title: string }>;
2377
+ },
2378
+ waitUntil(args.wait_until),
2379
+ timeout,
2380
+ ).catch(() => undefined);
2381
+ if (!liveIdentity?.url || isBlankPage(liveIdentity.url) || comparableUrl(liveIdentity.url) !== comparableUrl(target.url)) {
2382
+ page = await navigateNativePageWithReplacement(page);
2383
+ }
1229
2384
  }
1230
- if (comparableUrl(page.url()) !== comparableUrl(target.url)) {
2385
+ const explicitUserOwnedUrlMatch = effectiveOwnership === 'user_owned'
2386
+ && savedProduct
2387
+ && explicitNativeAdoptionUrlsMatch(target.url, page.url(), savedProduct);
2388
+ if (comparableUrl(page.url()) !== comparableUrl(target.url) && !explicitUserOwnedUrlMatch) {
1231
2389
  const currentRef = (page as unknown as { tabRef?: () => MacOsBrowserTabRef | undefined }).tabRef?.();
2390
+ if (effectiveOwnership === 'user_owned') {
2391
+ if (matchedBy === 'recovered_tab') {
2392
+ sessionResume = { sessionId: target.sessionId, status: 'matched', reason: 'Recovered current user tab was already within the same saved app entity; preserved its current URL instead of navigating it.', savedTab };
2393
+ } else {
2394
+ throw new AssistantPluginError(
2395
+ 'PLUGIN_BROWSER_ADOPTED_TAB_URL_MISMATCH',
2396
+ 'Explicitly adopted user-owned tab no longer matches the saved session URL; refusing to navigate, replace, or close the user tab.',
2397
+ { retryable: true, details: { sessionId: target.sessionId, expectedUrl: target.url, actualUrl: page.url(), windowId: savedTab?.windowId, tabId: savedTab?.tabId } },
2398
+ );
2399
+ }
2400
+ }
1232
2401
  const sessionTargetUnchanged = Boolean(target.existingSession?.url) && comparableUrl(target.url) === comparableUrl(target.existingSession?.url ?? '');
1233
2402
  const preserveOwnedSameOriginDrift = matchedBy === 'owned_token' && sessionTargetUnchanged && sameOrigin(page.url(), target.url);
1234
- if (preserveOwnedSameOriginDrift) {
2403
+ if (matchedBy === 'recovered_tab' && effectiveOwnership === 'user_owned') {
2404
+ // Safe recovery adopts an already-open tab. Its current page is authoritative and must not be navigated.
2405
+ } else if (preserveOwnedSameOriginDrift) {
1235
2406
  sessionResume = { sessionId: target.sessionId, status: 'matched', reason: 'Plugin-owned tab navigated within the same origin; preserved the live tab and refreshed its session URL.', savedTab };
1236
- } else if (!currentRef) {
1237
- await page.goto(target.url, { waitUntil: waitUntil(args.wait_until), timeout });
1238
2407
  } else {
1239
- const replacement = await createMacOsBrowserOwnedPage(discovered.attachment, target.url, timeout) as unknown as PageLike;
1240
- const createdRef = (replacement as unknown as { tabRef?: () => MacOsBrowserTabRef | undefined }).tabRef?.();
1241
- if (!createdRef) throw new AssistantPluginError('PLUGIN_BROWSER_NATIVE_OWNERSHIP_MISSING', 'Replacement native browser tab did not return a stable plugin-owned tab reference.', { retryable: true });
1242
- replacedRef = currentRef;
1243
- createdThisCallRef = createdRef;
1244
- page = replacement;
1245
- matchedBy = 'new_page';
1246
- sessionResume = { sessionId: target.sessionId, status: 'matched', reason: 'Reattached to the saved session by replacing its previous plugin-owned background tab.', savedTab };
2408
+ page = await navigateNativePageWithReplacement(page);
2409
+ if (currentRef && !navigationUsedReplacement) {
2410
+ matchedBy = 'owned_token';
2411
+ sessionResume = { sessionId: target.sessionId, status: 'matched', reason: 'Navigated the existing plugin-owned tab in place and preserved its stable tab identity.', savedTab };
2412
+ }
1247
2413
  }
1248
2414
  }
1249
- if (matchedBy !== 'owned_token' && page.waitForLoadState) {
2415
+ const explicitInternalUserSession = Boolean(
2416
+ target.existingSession
2417
+ && isExplicitNativeUserOwnedSession(target.existingSession)
2418
+ && browserInternalUrlProduct(target.url) === savedProduct,
2419
+ );
2420
+ if (matchedBy !== 'owned_token' && page.waitForLoadState && !explicitInternalUserSession) {
1250
2421
  await page.waitForLoadState(waitUntil(args.wait_until), { timeout });
1251
2422
  }
1252
- const pageUrl = normalizedUrl(page.url());
2423
+ const observedPageUrl = matchedBy === 'recovered_tab' && recoveredTabUrl ? recoveredTabUrl : page.url();
2424
+ const pageUrl = explicitInternalUserSession
2425
+ ? normalizedExplicitNativeAdoptionUrl(observedPageUrl)
2426
+ : normalizedUrl(observedPageUrl);
1253
2427
  const nativeRef = (page as unknown as { tabRef?: () => MacOsBrowserTabRef | undefined }).tabRef?.();
1254
2428
  if (!nativeRef) throw new AssistantPluginError('PLUGIN_BROWSER_NATIVE_OWNERSHIP_MISSING', 'Native browser did not return a stable plugin-owned tab reference.', { retryable: true });
1255
- const title = matchedBy === 'owned_token' ? (target.existingSession?.title ?? '') : '';
2429
+ const title = matchedBy === 'owned_token' ? (target.existingSession?.title ?? '') : matchedBy === 'recovered_tab' ? recoveredTabTitle : '';
1256
2430
  const inventory: BrowserTabInventoryEntry[] = [{ index: 0, key: tabKey(pageUrl, title), url: pageUrl, title }];
1257
- const metadata = discovered.attachment.metadata;
2431
+ const metadata = activeAttachment.metadata;
2432
+ // The live exact-tab URL is authoritative after a safe native rebind. Keep the
2433
+ // action-local target synchronized so read/query responses and the persisted
2434
+ // session report the same observed URL in this round.
2435
+ target.url = pageUrl;
1258
2436
  const connection = refreshConnectionTab({
1259
2437
  requestedMode: 'attach_preferred',
1260
2438
  mode: 'attach_preferred',
1261
2439
  provider: 'macos-apple-events',
1262
2440
  attached: true,
1263
2441
  browserProduct: metadata.product,
2442
+ nativeBrowserCandidates: config.nativeBrowserCandidates,
1264
2443
  profile: {
1265
2444
  profileMode: config.profileMode,
1266
2445
  profileDirectory: config.profileDirectory,
@@ -1269,15 +2448,16 @@ async function openNativeAttachedContext(
1269
2448
  tabInventory: inventory,
1270
2449
  sessionResume,
1271
2450
  }, pageUrl, title, matchedBy, {
1272
- ownership: 'plugin_owned', windowId: nativeRef.windowId, tabId: nativeRef.tabId,
2451
+ ownership: effectiveOwnership === 'user_owned' ? 'user_owned' : 'plugin_owned',
2452
+ windowId: nativeRef.windowId,
2453
+ tabId: nativeRef.tabId,
1273
2454
  });
1274
2455
  const diagnostics: PageDiagnostics = { consoleErrors: [], failedRequests: [], navigation: { url: pageUrl } };
1275
- if (replacedRef) await closeMacOsBrowserOwnedTab(metadata.product, replacedRef, timeout);
1276
2456
  createdThisCallRef = undefined;
1277
2457
  return { attempts: discovered.attempts, handle: { page, diagnostics, connection, close: async () => undefined } };
1278
2458
  } catch (error) {
1279
2459
  if (createdThisCallRef) {
1280
- await closeMacOsBrowserOwnedTab(discovered.attachment.metadata.product, createdThisCallRef, timeout).catch(() => undefined);
2460
+ await closeMacOsBrowserOwnedTab(activeAttachment.metadata.product, createdThisCallRef, timeout).catch(() => undefined);
1281
2461
  }
1282
2462
  throw error;
1283
2463
  }
@@ -1291,15 +2471,43 @@ async function openBrowser(
1291
2471
  ): Promise<BrowserOpenHandle> {
1292
2472
  const requestedMode = config.browserMode ?? 'managed_persistent';
1293
2473
  if (requestedMode === 'attach_preferred') {
1294
- const attached = cdpEndpoints(config).length > 0 && runtimeHooks.moduleAvailable('playwright')
1295
- ? await openAttachedContext(runtimeHooks.loadPlaywright(), repoRoot, config, target, args)
2474
+ const requireExistingResource = args.__forge_require_existing_resource === true;
2475
+ const existingProvider = target.existingSession?.browser?.provider;
2476
+ const attached = cdpEndpoints(config).length > 0 && runtimeHooks.moduleAvailable('playwright', repoRoot)
2477
+ ? await openAttachedContext(runtimeHooks.loadPlaywright(repoRoot), repoRoot, config, target, args)
1296
2478
  : { attempts: [] as CdpAttachAttempt[] };
1297
2479
  if (attached.handle) return attached.handle;
2480
+ if (requireExistingResource && existingProvider === 'playwright-cdp') {
2481
+ throw new AssistantPluginError(
2482
+ 'PLUGIN_BROWSER_SESSION_STATE_LOST',
2483
+ 'Saved CDP browser page is no longer attachable; refusing to fall back to another browser or create a replacement page.',
2484
+ { retryable: false, details: { sessionId: target.sessionId, provider: existingProvider } },
2485
+ );
2486
+ }
2487
+ if (requireExistingResource && existingProvider === 'playwright-persistent-context') {
2488
+ if (!runtimeHooks.moduleAvailable('playwright', repoRoot)) {
2489
+ throw new AssistantPluginError('PLUGIN_BROWSER_DEPENDENCY_UNAVAILABLE', 'Managed browser mode requires Playwright.', { retryable: false });
2490
+ }
2491
+ return openManagedContext(runtimeHooks.loadPlaywright(repoRoot), repoRoot, config, target, args, 'managed_persistent', requestedMode, {
2492
+ policy: config.cdpAttachFallback ?? 'fail_closed',
2493
+ from: 'attach_preferred',
2494
+ to: 'managed_persistent',
2495
+ reason: 'Reusing the existing managed fallback session without probing or creating a different browser resource.',
2496
+ attempts: attached.attempts,
2497
+ });
2498
+ }
1298
2499
 
1299
2500
  const native = await openNativeAttachedContext(repoRoot, config, target, args);
1300
2501
  if (native.handle) return native.handle;
2502
+ if (requireExistingResource) {
2503
+ throw new AssistantPluginError(
2504
+ 'PLUGIN_BROWSER_SESSION_STATE_LOST',
2505
+ 'Saved browser resource is no longer attachable; refusing to create a replacement tab/page for this existing-session action.',
2506
+ { retryable: false, details: { sessionId: target.sessionId, provider: existingProvider } },
2507
+ );
2508
+ }
1301
2509
 
1302
- const fallbackPolicy = config.cdpAttachFallback ?? 'managed_persistent';
2510
+ const fallbackPolicy = config.cdpAttachFallback ?? 'fail_closed';
1303
2511
  const cdpReason = attached.attempts.map((attempt) => `${attempt.endpoint}: ${attempt.error ?? 'unavailable'}`).join('; ')
1304
2512
  || 'No CDP endpoint candidates were configured.';
1305
2513
  const nativeReason = native.attempts.map((attempt) => `${attempt.appName}: ${attempt.error ?? attempt.status}`).join('; ')
@@ -1316,10 +2524,10 @@ async function openBrowser(
1316
2524
  },
1317
2525
  });
1318
2526
  }
1319
- if (!runtimeHooks.moduleAvailable('playwright')) {
2527
+ if (!runtimeHooks.moduleAvailable('playwright', repoRoot)) {
1320
2528
  throw new AssistantPluginError('PLUGIN_BROWSER_DEPENDENCY_UNAVAILABLE', `Managed browser fallback requires Playwright, but native/CDP attach was unavailable. ${reason}`, { retryable: false });
1321
2529
  }
1322
- return openManagedContext(runtimeHooks.loadPlaywright(), repoRoot, config, target, args, 'managed_persistent', requestedMode, {
2530
+ return openManagedContext(runtimeHooks.loadPlaywright(repoRoot), repoRoot, config, target, args, 'managed_persistent', requestedMode, {
1323
2531
  policy: fallbackPolicy,
1324
2532
  from: 'attach_preferred',
1325
2533
  to: 'managed_persistent',
@@ -1329,10 +2537,10 @@ async function openBrowser(
1329
2537
  });
1330
2538
  }
1331
2539
 
1332
- if (!runtimeHooks.moduleAvailable('playwright')) {
2540
+ if (!runtimeHooks.moduleAvailable('playwright', repoRoot)) {
1333
2541
  throw new AssistantPluginError('PLUGIN_BROWSER_DEPENDENCY_UNAVAILABLE', 'Managed browser mode requires Playwright.', { retryable: false });
1334
2542
  }
1335
- return openManagedContext(runtimeHooks.loadPlaywright(), repoRoot, config, target, args, requestedMode, requestedMode);
2543
+ return openManagedContext(runtimeHooks.loadPlaywright(repoRoot), repoRoot, config, target, args, requestedMode, requestedMode);
1336
2544
  }
1337
2545
 
1338
2546
  async function withPage<T>(
@@ -1341,22 +2549,49 @@ async function withPage<T>(
1341
2549
  target: BrowserActionTarget,
1342
2550
  args: Record<string, unknown>,
1343
2551
  run: (page: PageLike, diagnostics: PageDiagnostics, connection: BrowserConnectionSummary) => Promise<T>,
1344
- options: { persistSession?: boolean } = {},
2552
+ options: { persistSession?: boolean; requireExistingResource?: boolean; pruneStaleSessionMetadata?: boolean } = {},
1345
2553
  ): Promise<T> {
1346
2554
  const retries = Math.min(Math.max(positiveNumber(args.retries, 1), 1), 3);
1347
2555
  let lastError: unknown;
1348
2556
  for (let attempt = 1; attempt <= retries; attempt += 1) {
1349
2557
  let handle: BrowserOpenHandle | undefined;
1350
2558
  try {
1351
- handle = await openBrowser(repoRoot, config, target, args);
2559
+ if (target.existingSession) assertBrowserSessionAvailable(repoRoot, target.sessionId);
2560
+ const requireExistingResource = options.requireExistingResource ?? Boolean(target.existingSession);
2561
+ const browserArgs = requireExistingResource
2562
+ ? { ...args, __forge_require_existing_resource: true }
2563
+ : args;
2564
+ handle = await openBrowser(repoRoot, config, target, browserArgs);
1352
2565
  const result = await run(handle.page, handle.diagnostics, handle.connection);
1353
2566
  if (options.persistSession) {
1354
- const identity = await readPageIdentity(handle.page, handle.connection);
2567
+ const identity = await readPageIdentity(handle.page, handle.connection, target.url);
1355
2568
  saveSession(repoRoot, sessionFromPage(target, identity.url, identity.title, handle.connection));
2569
+ if (result && typeof result === 'object' && !Array.isArray(result) && Object.prototype.hasOwnProperty.call(result, 'url')) {
2570
+ (result as Record<string, unknown>).url = identity.url;
2571
+ }
1356
2572
  }
1357
2573
  return result;
1358
2574
  } catch (error) {
1359
2575
  lastError = error;
2576
+ const nativeTab = handle?.connection.provider === 'macos-apple-events' ? handle.connection.tab : undefined;
2577
+ if (handle?.connection.provider === 'macos-apple-events'
2578
+ && handle.connection.browserProduct
2579
+ && nativeTab?.windowId
2580
+ && nativeTab.tabId
2581
+ && macOsBrowserPageHandleStale(error)) {
2582
+ // Do not replay the current action. Evict only so the next independent action must
2583
+ // reattach/recover through the existing cold-path identity checks.
2584
+ invalidateMacOsBrowserPageHandle(handle.connection.browserProduct, {
2585
+ windowId: nativeTab.windowId,
2586
+ tabId: nativeTab.tabId,
2587
+ });
2588
+ }
2589
+ if (options.pruneStaleSessionMetadata
2590
+ && target.existingSession
2591
+ && error instanceof AssistantPluginError
2592
+ && error.code === 'PLUGIN_BROWSER_SESSION_STATE_LOST') {
2593
+ removeSession(repoRoot, target.sessionId);
2594
+ }
1360
2595
  const message = error instanceof Error ? error.message : String(error);
1361
2596
  const transient = /timeout|net::|ERR_|Navigation failed|Target closed|Protocol error/i.test(message);
1362
2597
  if (!transient || attempt >= retries) throw error;
@@ -1368,17 +2603,319 @@ async function withPage<T>(
1368
2603
  }
1369
2604
 
1370
2605
  function listSavedSessions(repoRoot: string): BrowserSessionState[] {
1371
- const dir = stateDir(repoRoot, 'sessions');
1372
- if (!existsSync(dir)) return [];
2606
+ return listSavedBrowserSessions(repoRoot)
2607
+ .filter((entry) => Boolean(entry.sessionId && entry.url))
2608
+ .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
2609
+ }
2610
+
2611
+ function nativeTabInventoryUnsupported(error: unknown): boolean {
2612
+ if (error instanceof AssistantPluginError && error.code === 'BROWSER_AUTOMATION_ACTION_UNSUPPORTED') return true;
2613
+ const message = error instanceof Error ? error.message : String(error ?? '');
2614
+ return /\bBROWSER_AUTOMATION_ACTION_UNSUPPORTED\b/.test(message);
2615
+ }
2616
+
2617
+ function isDiscardableNativeOwnedTabUrl(url: string): boolean {
2618
+ const normalized = url.trim().toLowerCase();
2619
+ return isBlankPage(normalized)
2620
+ || normalized === 'chrome://new-tab-page/'
2621
+ || normalized === 'vivaldi://newtab/'
2622
+ || normalized === 'vivaldi://startpage/';
2623
+ }
2624
+
2625
+ type NativeOwnedSessionInspection = {
2626
+ items: Map<string, BrowserSessionInventoryItem>;
2627
+ liveCount: number;
2628
+ deadCount: number;
2629
+ unverifiedCount: number;
2630
+ prunedCount: number;
2631
+ closedInvalidCount: number;
2632
+ failedCleanupCount: number;
2633
+ };
2634
+
2635
+ async function inspectNativeOwnedSessions(
2636
+ repoRoot: string,
2637
+ config: BrowserPluginConfig,
2638
+ saved: BrowserSessionState[],
2639
+ options: { pruneDead?: boolean } = {},
2640
+ ): Promise<NativeOwnedSessionInspection> {
2641
+ const items = new Map<string, BrowserSessionInventoryItem>();
2642
+ const inventories = new Map<MacOsBrowserProduct, Awaited<ReturnType<typeof listMacOsBrowserTabs>> | Error>();
2643
+ const groups = new Map<string, BrowserSessionState[]>();
2644
+ let liveCount = 0;
2645
+ let deadCount = 0;
2646
+ let unverifiedCount = 0;
2647
+ let prunedCount = 0;
2648
+ let closedInvalidCount = 0;
2649
+ let failedCleanupCount = 0;
2650
+ const timeout = config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
2651
+
2652
+ for (const session of saved) {
2653
+ const browser = session.browser;
2654
+ const tab = browser?.tab;
2655
+ const product = browser?.browserProduct;
2656
+ if (browser?.provider !== 'macos-apple-events' || tab?.ownership !== 'plugin_owned' || !product || !tab.windowId || !tab.tabId) continue;
2657
+ const key = `${product}:${tab.windowId}:${tab.tabId}`;
2658
+ const group = groups.get(key) ?? [];
2659
+ group.push(session);
2660
+ groups.set(key, group);
2661
+ }
2662
+
2663
+ for (const group of groups.values()) {
2664
+ const canonical = group.reduce((latest, session) => session.updatedAt > latest.updatedAt ? session : latest);
2665
+ const browser = canonical.browser!;
2666
+ const tab = browser.tab!;
2667
+ const product = browser.browserProduct!;
2668
+ let inventory = inventories.get(product);
2669
+ if (!inventory) {
2670
+ try {
2671
+ inventory = await listMacOsBrowserTabs(product, timeout);
2672
+ } catch (error) {
2673
+ inventory = error instanceof Error ? error : new Error(String(error));
2674
+ }
2675
+ inventories.set(product, inventory);
2676
+ }
2677
+ if (inventory instanceof Error) {
2678
+ if (!nativeTabInventoryUnsupported(inventory)) {
2679
+ for (const session of group) items.set(session.sessionId, { session, liveness: 'unverified', evidence: 'native_inventory_unavailable', cleanupError: inventory.message });
2680
+ unverifiedCount += group.length;
2681
+ continue;
2682
+ }
2683
+
2684
+ const ref = { windowId: tab.windowId!, tabId: tab.tabId! };
2685
+ let metadata: Awaited<ReturnType<typeof readMacOsBrowserOwnedTabMetadata>>;
2686
+ try {
2687
+ metadata = await readMacOsBrowserOwnedTabMetadata(product, ref, timeout);
2688
+ } catch (metadataError) {
2689
+ if (macOsBrowserPageHandleStale(metadataError)) {
2690
+ const pruned = options.pruneDead === true;
2691
+ if (pruned) {
2692
+ for (const session of group) removeSession(repoRoot, session.sessionId);
2693
+ prunedCount += group.length;
2694
+ }
2695
+ for (const session of group) items.set(session.sessionId, { session, liveness: 'dead', evidence: 'native_tab_missing', pruned });
2696
+ deadCount += group.length;
2697
+ } else {
2698
+ const cleanupError = metadataError instanceof Error ? metadataError.message : String(metadataError);
2699
+ for (const session of group) items.set(session.sessionId, { session, liveness: 'unverified', evidence: 'native_inventory_unavailable', cleanupError });
2700
+ unverifiedCount += group.length;
2701
+ }
2702
+ continue;
2703
+ }
2704
+
2705
+ if (isDiscardableNativeOwnedTabUrl(metadata.url)) {
2706
+ deadCount += group.length;
2707
+ if (options.pruneDead === true) {
2708
+ try {
2709
+ await closeMacOsBrowserOwnedTab(product, ref, timeout);
2710
+ for (const session of group) removeSession(repoRoot, session.sessionId);
2711
+ prunedCount += group.length;
2712
+ closedInvalidCount += 1;
2713
+ for (const session of group) items.set(session.sessionId, { session, liveness: 'dead', evidence: 'native_tab_invalid_closed', pruned: true });
2714
+ } catch (error) {
2715
+ failedCleanupCount += 1;
2716
+ const cleanupError = error instanceof Error ? error.message : String(error);
2717
+ for (const session of group) items.set(session.sessionId, { session, liveness: 'dead', evidence: 'native_tab_invalid_close_failed', cleanupError });
2718
+ }
2719
+ } else {
2720
+ for (const session of group) items.set(session.sessionId, { session, liveness: 'dead', evidence: 'native_tab_invalid' });
2721
+ }
2722
+ } else {
2723
+ for (const session of group) items.set(session.sessionId, { session, liveness: 'live', evidence: 'native_tab_live' });
2724
+ liveCount += group.length;
2725
+ }
2726
+ continue;
2727
+ }
2728
+
2729
+ const live = inventory.tabs.find((entry) => entry.windowId === tab.windowId && entry.tabId === tab.tabId);
2730
+ if (!live) {
2731
+ const pruned = options.pruneDead === true;
2732
+ if (pruned) {
2733
+ for (const session of group) removeSession(repoRoot, session.sessionId);
2734
+ prunedCount += group.length;
2735
+ }
2736
+ for (const session of group) items.set(session.sessionId, { session, liveness: 'dead', evidence: 'native_tab_missing', pruned });
2737
+ deadCount += group.length;
2738
+ continue;
2739
+ }
2740
+
2741
+ if (isDiscardableNativeOwnedTabUrl(live.url)) {
2742
+ deadCount += group.length;
2743
+ if (options.pruneDead === true) {
2744
+ try {
2745
+ await closeMacOsBrowserOwnedTab(product, { windowId: tab.windowId!, tabId: tab.tabId! }, timeout);
2746
+ for (const session of group) removeSession(repoRoot, session.sessionId);
2747
+ prunedCount += group.length;
2748
+ closedInvalidCount += 1;
2749
+ for (const session of group) items.set(session.sessionId, { session, liveness: 'dead', evidence: 'native_tab_invalid_closed', pruned: true });
2750
+ } catch (error) {
2751
+ failedCleanupCount += 1;
2752
+ const cleanupError = error instanceof Error ? error.message : String(error);
2753
+ for (const session of group) items.set(session.sessionId, { session, liveness: 'dead', evidence: 'native_tab_invalid_close_failed', cleanupError });
2754
+ }
2755
+ } else {
2756
+ for (const session of group) items.set(session.sessionId, { session, liveness: 'dead', evidence: 'native_tab_invalid' });
2757
+ }
2758
+ continue;
2759
+ }
2760
+
2761
+ for (const session of group) items.set(session.sessionId, { session, liveness: 'live', evidence: 'native_tab_live' });
2762
+ liveCount += group.length;
2763
+ }
2764
+
2765
+ return { items, liveCount, deadCount, unverifiedCount, prunedCount, closedInvalidCount, failedCleanupCount };
2766
+ }
2767
+
2768
+ async function closeTrackedNativeOwnedSession(
2769
+ session: BrowserSessionState,
2770
+ config: BrowserPluginConfig,
2771
+ ): Promise<{ resourceClosed: boolean; resourceAlreadyMissing: boolean }> {
2772
+ const browser = session.browser;
2773
+ const tab = browser?.tab;
2774
+ if (browser?.provider !== 'macos-apple-events' || tab?.ownership !== 'plugin_owned') {
2775
+ return { resourceClosed: false, resourceAlreadyMissing: false };
2776
+ }
2777
+ if (!browser.browserProduct || !tab.windowId || !tab.tabId) {
2778
+ throw new AssistantPluginError('PLUGIN_BROWSER_NATIVE_OWNERSHIP_MISSING', 'Tracked plugin-owned native tab is missing stable browser identity; retaining session metadata instead of orphaning the tab.', {
2779
+ retryable: false, details: { sessionId: session.sessionId, browserProduct: browser.browserProduct, windowId: tab.windowId, tabId: tab.tabId },
2780
+ });
2781
+ }
2782
+ const timeout = config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
2783
+ const ref = { windowId: tab.windowId, tabId: tab.tabId };
2784
+ let inventory: Awaited<ReturnType<typeof listMacOsBrowserTabs>> | undefined;
1373
2785
  try {
1374
- return readdirSync(dir)
1375
- .filter((name) => name.endsWith('.json'))
1376
- .map((name) => readJson<BrowserSessionState>(join(dir, name)))
1377
- .filter((entry): entry is BrowserSessionState => Boolean(entry?.sessionId && entry.url))
1378
- .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
1379
- } catch {
1380
- return [];
2786
+ inventory = await listMacOsBrowserTabs(browser.browserProduct, timeout);
2787
+ } catch (error) {
2788
+ if (!nativeTabInventoryUnsupported(error)) throw error;
2789
+ try {
2790
+ await readMacOsBrowserOwnedTabMetadata(browser.browserProduct, ref, timeout);
2791
+ } catch (metadataError) {
2792
+ if (macOsBrowserPageHandleStale(metadataError)) return { resourceClosed: false, resourceAlreadyMissing: true };
2793
+ throw metadataError;
2794
+ }
2795
+ }
2796
+ if (inventory && !inventory.tabs.some((entry) => entry.windowId === tab.windowId && entry.tabId === tab.tabId)) {
2797
+ return { resourceClosed: false, resourceAlreadyMissing: true };
2798
+ }
2799
+ await closeMacOsBrowserOwnedTab(browser.browserProduct, ref, timeout);
2800
+ return { resourceClosed: true, resourceAlreadyMissing: false };
2801
+ }
2802
+
2803
+ function nativeOwnedAliasSessionIds(repoRoot: string, session: BrowserSessionState): string[] {
2804
+ const browser = session.browser;
2805
+ const tab = browser?.tab;
2806
+ if (browser?.provider !== 'macos-apple-events' || tab?.ownership !== 'plugin_owned' || !browser.browserProduct || !tab.windowId || !tab.tabId) {
2807
+ return [session.sessionId];
2808
+ }
2809
+ return listSavedSessions(repoRoot)
2810
+ .filter((candidate) => candidate.browser?.provider === 'macos-apple-events'
2811
+ && candidate.browser.browserProduct === browser.browserProduct
2812
+ && candidate.browser.tab?.ownership === 'plugin_owned'
2813
+ && candidate.browser.tab.windowId === tab.windowId
2814
+ && candidate.browser.tab.tabId === tab.tabId)
2815
+ .map((candidate) => candidate.sessionId);
2816
+ }
2817
+
2818
+ async function inspectSavedSessions(
2819
+ repoRoot: string,
2820
+ config: BrowserPluginConfig,
2821
+ options: { pruneDead?: boolean } = {},
2822
+ ): Promise<BrowserSessionInventory> {
2823
+ const saved = listSavedSessions(repoRoot);
2824
+ const sessions: BrowserSessionInventoryItem[] = [];
2825
+ let liveManagedSessionCount = 0;
2826
+ let unverifiedSessionCount = 0;
2827
+ let deadManagedSessionCount = 0;
2828
+ let prunedSessionCount = 0;
2829
+ const native = await inspectNativeOwnedSessions(repoRoot, config, saved, options);
2830
+ let liveNativeSessionCount = native.liveCount;
2831
+ let deadNativeSessionCount = native.deadCount;
2832
+ let closedInvalidNativeSessionCount = native.closedInvalidCount;
2833
+ let failedNativeCleanupCount = native.failedCleanupCount;
2834
+ prunedSessionCount += native.prunedCount;
2835
+ unverifiedSessionCount += native.unverifiedCount;
2836
+
2837
+ for (const session of saved) {
2838
+ const nativeItem = native.items.get(session.sessionId);
2839
+ if (nativeItem) {
2840
+ sessions.push(nativeItem);
2841
+ continue;
2842
+ }
2843
+ if (session.browser?.provider !== 'playwright-persistent-context' || session.browser.activeMode !== 'managed_persistent') {
2844
+ sessions.push({ session, liveness: 'unverified', evidence: 'provider_unverified' });
2845
+ unverifiedSessionCount += 1;
2846
+ continue;
2847
+ }
2848
+
2849
+ const profile = selectedProfile(config, repoRoot, 'managed_persistent', session.sessionId);
2850
+ const pending = managedBrowserContexts.get(managedContextKey(profile));
2851
+ if (!pending) {
2852
+ sessions.push({ session, liveness: 'unverified', evidence: 'runtime_context_unavailable' });
2853
+ unverifiedSessionCount += 1;
2854
+ continue;
2855
+ }
2856
+
2857
+ let state: ManagedBrowserContextState;
2858
+ try {
2859
+ state = await pending;
2860
+ } catch {
2861
+ sessions.push({ session, liveness: 'unverified', evidence: 'runtime_context_unavailable' });
2862
+ unverifiedSessionCount += 1;
2863
+ continue;
2864
+ }
2865
+
2866
+ const pages = state.context.pages();
2867
+ const boundPage = state.pagesBySession.get(session.sessionId);
2868
+ if (boundPage) {
2869
+ if (pages.includes(boundPage)) {
2870
+ sessions.push({ session, liveness: 'live', evidence: 'runtime_session_binding' });
2871
+ liveManagedSessionCount += 1;
2872
+ continue;
2873
+ }
2874
+
2875
+ const pruned = options.pruneDead === true;
2876
+ if (pruned) {
2877
+ state.pagesBySession.delete(session.sessionId);
2878
+ removeSession(repoRoot, session.sessionId);
2879
+ prunedSessionCount += 1;
2880
+ }
2881
+ sessions.push({ session, liveness: 'dead', evidence: 'runtime_bound_page_missing', pruned });
2882
+ deadManagedSessionCount += 1;
2883
+ continue;
2884
+ }
2885
+
2886
+ const expectedOwnerToken = session.browser.tab?.ownership === 'plugin_owned' ? session.browser.tab.ownerToken : undefined;
2887
+ if (expectedOwnerToken) {
2888
+ const inventory = await inventoryTabs(state.context).catch(() => [] as BrowserTabInventoryEntry[]);
2889
+ const index = inventory.findIndex((entry) => entry.ownerToken === expectedOwnerToken);
2890
+ const page = index >= 0 ? state.context.pages()[index] : undefined;
2891
+ if (page) {
2892
+ state.pagesBySession.set(session.sessionId, page);
2893
+ sessions.push({ session, liveness: 'live', evidence: 'runtime_owner_token' });
2894
+ liveManagedSessionCount += 1;
2895
+ continue;
2896
+ }
2897
+ sessions.push({ session, liveness: 'unverified', evidence: 'runtime_owner_token_not_observed' });
2898
+ unverifiedSessionCount += 1;
2899
+ continue;
2900
+ }
2901
+
2902
+ sessions.push({ session, liveness: 'unverified', evidence: 'provider_unverified' });
2903
+ unverifiedSessionCount += 1;
1381
2904
  }
2905
+
2906
+ return {
2907
+ sessions,
2908
+ scannedSessionCount: saved.length,
2909
+ savedSessionCount: saved.length - prunedSessionCount,
2910
+ liveManagedSessionCount,
2911
+ liveNativeSessionCount,
2912
+ unverifiedSessionCount,
2913
+ deadManagedSessionCount,
2914
+ deadNativeSessionCount,
2915
+ closedInvalidNativeSessionCount,
2916
+ failedNativeCleanupCount,
2917
+ prunedSessionCount,
2918
+ };
1382
2919
  }
1383
2920
 
1384
2921
  const STABLE_SELECTOR_HELPERS = `
@@ -1459,7 +2996,7 @@ const STABLE_SELECTOR_HELPERS = `
1459
2996
  const EXTRACTION_SCRIPTS = {
1460
2997
  query: (selector: string, limit: number) => `(() => {
1461
2998
  ${STABLE_SELECTOR_HELPERS}
1462
- const nodes = Array.from(document.querySelectorAll(${JSON.stringify(selector)})).slice(0, ${limit});
2999
+ const nodes = ${openShadowSelectorExpression(selector, true)}.slice(0, ${limit});
1463
3000
  return nodes.map((el) => {
1464
3001
  const role = el.getAttribute('role');
1465
3002
  const text = (el.innerText || el.textContent || '').trim().slice(0, 120);
@@ -1467,11 +3004,11 @@ const EXTRACTION_SCRIPTS = {
1467
3004
  });
1468
3005
  })()`,
1469
3006
  attribute: (selector: string, attribute: string) => `(() => {
1470
- const el = document.querySelector(${JSON.stringify(selector)});
3007
+ const el = ${openShadowSelectorExpression(selector)};
1471
3008
  return el ? el.getAttribute(${JSON.stringify(attribute)}) : null;
1472
3009
  })()`,
1473
3010
  html: (selector?: string) => selector
1474
- ? `(() => { const el = document.querySelector(${JSON.stringify(selector)}); return el ? el.outerHTML : ''; })()`
3011
+ ? `(() => { const el = ${openShadowSelectorExpression(selector)}; return el ? el.outerHTML : ''; })()`
1475
3012
  : 'document.documentElement ? document.documentElement.outerHTML : ""',
1476
3013
  links: (limit: number) => `(() => {
1477
3014
  ${STABLE_SELECTOR_HELPERS}
@@ -1501,10 +3038,44 @@ const EXTRACTION_SCRIPTS = {
1501
3038
  })()`,
1502
3039
  interactive: (limit: number) => `(() => {
1503
3040
  ${STABLE_SELECTOR_HELPERS}
1504
- return Array.from(document.querySelectorAll('a,button,input,select,textarea,[role="button"],[role="link"]')).slice(0, ${limit}).map((el) => {
3041
+ const viewport = {
3042
+ width: Number(window.innerWidth || document.documentElement?.clientWidth || 0),
3043
+ height: Number(window.innerHeight || document.documentElement?.clientHeight || 0),
3044
+ scrollX: Number(window.scrollX || window.pageXOffset || 0),
3045
+ scrollY: Number(window.scrollY || window.pageYOffset || 0),
3046
+ devicePixelRatio: Number(window.devicePixelRatio || 1),
3047
+ };
3048
+ const elements = Array.from(document.querySelectorAll('a,button,input,select,textarea,[contenteditable="true"],[role="button"],[role="link"],[role="textbox"]')).slice(0, ${limit}).map((el) => {
1505
3049
  const text = (el.innerText || el.getAttribute('aria-label') || el.getAttribute('value') || el.textContent || '').trim().slice(0, 100);
1506
- return { tag: el.tagName.toLowerCase(), text, type: el.getAttribute('type') || undefined, href: el.getAttribute('href') || undefined, selectorHint: forgeSelectorHint(el) };
3050
+ const rect = el.getBoundingClientRect();
3051
+ const style = typeof getComputedStyle === 'function' ? getComputedStyle(el) : undefined;
3052
+ const width = Number(rect.width || 0);
3053
+ const height = Number(rect.height || 0);
3054
+ const left = Number(rect.left || 0);
3055
+ const top = Number(rect.top || 0);
3056
+ const right = Number(rect.right || (left + width));
3057
+ const bottom = Number(rect.bottom || (top + height));
3058
+ const visible = width > 0 && height > 0 && style?.display !== 'none' && style?.visibility !== 'hidden' && Number(style?.opacity ?? 1) !== 0;
3059
+ const inViewport = visible && right > 0 && bottom > 0 && left < viewport.width && top < viewport.height;
3060
+ const disabled = Boolean(el.disabled || el.getAttribute('aria-disabled') === 'true');
3061
+ const editable = !disabled && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable || el.getAttribute('role') === 'textbox');
3062
+ return {
3063
+ tag: el.tagName.toLowerCase(),
3064
+ text,
3065
+ type: el.getAttribute('type') || undefined,
3066
+ href: el.getAttribute('href') || undefined,
3067
+ role: el.getAttribute('role') || undefined,
3068
+ ariaLabel: el.getAttribute('aria-label') || undefined,
3069
+ selectorHint: forgeSelectorHint(el),
3070
+ bounds: { x: left, y: top, width, height, right, bottom },
3071
+ center: { x: left + width / 2, y: top + height / 2 },
3072
+ visible,
3073
+ inViewport,
3074
+ disabled,
3075
+ editable,
3076
+ };
1507
3077
  });
3078
+ return { geometryVersion: 1, viewport, elements };
1508
3079
  })()`,
1509
3080
  };
1510
3081
 
@@ -1518,17 +3089,229 @@ function selectorRepairHint(selector: string, errorMessage: string): string {
1518
3089
  return `Check selector "${selector}" against the current page structure.`;
1519
3090
  }
1520
3091
 
1521
- async function extractText(page: PageLike, selector: string | undefined, maxChars: number): Promise<Record<string, unknown>> {
1522
- const raw = await page.evaluate<string>(scriptText(selector));
1523
- return truncateText(raw, maxChars);
3092
+ type BrowserEvaluationScope = Pick<PageLike, 'evaluate'>;
3093
+
3094
+ type BrowserFrameSelection = {
3095
+ scope: BrowserEvaluationScope;
3096
+ frame?: { url: string; name: string };
3097
+ frameHandle?: FrameLike;
3098
+ };
3099
+
3100
+ function requestedFrameSelection(args: Record<string, unknown>): { frameUrl?: string; frameName?: string } {
3101
+ return { frameUrl: stringValue(args.frame_url), frameName: stringValue(args.frame_name) };
3102
+ }
3103
+
3104
+ function resolveBrowserEvaluationScope(
3105
+ page: PageLike,
3106
+ args: Record<string, unknown>,
3107
+ connection: BrowserConnectionSummary,
3108
+ ): BrowserFrameSelection {
3109
+ const { frameUrl, frameName } = requestedFrameSelection(args);
3110
+ if (!frameUrl && !frameName) return { scope: page };
3111
+ if (connection.provider === 'macos-apple-events' || !page.frames) {
3112
+ throw new AssistantPluginError(
3113
+ 'PLUGIN_BROWSER_FRAME_SCOPE_UNAVAILABLE',
3114
+ 'Explicit frame targeting requires a Playwright/CDP-controlled page; native Apple Events sessions fail closed.',
3115
+ { retryable: false, details: { provider: connection.provider, frameUrl, frameName } },
3116
+ );
3117
+ }
3118
+ const matches = page.frames().filter((frame) => (!frameUrl || frame.url() === frameUrl) && (!frameName || frame.name() === frameName));
3119
+ if (matches.length === 0) {
3120
+ throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_NOT_FOUND', 'No browser frame matched the requested exact frame identity.', {
3121
+ retryable: true,
3122
+ details: { frameUrl, frameName },
3123
+ });
3124
+ }
3125
+ if (matches.length > 1) {
3126
+ throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_AMBIGUOUS', 'Multiple browser frames matched the requested frame identity; specify both frame_url and frame_name.', {
3127
+ retryable: false,
3128
+ details: { frameUrl, frameName, matchCount: matches.length },
3129
+ });
3130
+ }
3131
+ const frame = matches[0]!;
3132
+ return { scope: frame, frame: { url: frame.url(), name: frame.name() }, frameHandle: frame };
3133
+ }
3134
+
3135
+ const VIEWPORT_METRICS_SCRIPT = `(() => ({
3136
+ viewportMetricsVersion: 1,
3137
+ viewport: {
3138
+ width: Number(window.innerWidth || document.documentElement?.clientWidth || 0),
3139
+ height: Number(window.innerHeight || document.documentElement?.clientHeight || 0),
3140
+ scrollX: Number(window.scrollX || window.pageXOffset || 0),
3141
+ scrollY: Number(window.scrollY || window.pageYOffset || 0),
3142
+ devicePixelRatio: Number(window.devicePixelRatio || 1),
3143
+ }
3144
+ }))()`;
3145
+
3146
+ async function resolveFrameViewportOffset(page: PageLike, selection: BrowserFrameSelection): Promise<{ x: number; y: number; width: number; height: number } | undefined> {
3147
+ const frame = selection.frameHandle;
3148
+ if (!frame) return undefined;
3149
+ if (page.mainFrame && page.mainFrame() === frame) return { x: 0, y: 0, width: 0, height: 0 };
3150
+ if (!frame.frameElement) {
3151
+ throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_GEOMETRY_UNAVAILABLE', 'The selected Playwright frame does not expose a frame element for coordinate grounding.', {
3152
+ retryable: false,
3153
+ details: selection.frame,
3154
+ });
3155
+ }
3156
+ let box: { x: number; y: number; width: number; height: number } | null;
3157
+ try {
3158
+ box = await (await frame.frameElement()).boundingBox();
3159
+ } catch (error) {
3160
+ throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_GEOMETRY_UNAVAILABLE', 'Could not resolve the selected frame element bounding box.', {
3161
+ retryable: true,
3162
+ details: { ...selection.frame, cause: error instanceof Error ? error.message : String(error) },
3163
+ });
3164
+ }
3165
+ if (!box || ![box.x, box.y, box.width, box.height].every((value) => Number.isFinite(value))) {
3166
+ throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_GEOMETRY_UNAVAILABLE', 'The selected frame element has no stable visible bounding box; refusing to guess trusted-input coordinates.', {
3167
+ retryable: true,
3168
+ details: selection.frame,
3169
+ });
3170
+ }
3171
+ return box;
3172
+ }
3173
+
3174
+ function translateFrameGroundingElements(elements: unknown[], offset: { x: number; y: number }, viewport: unknown): unknown[] {
3175
+ const viewportRecord = viewport && typeof viewport === 'object' && !Array.isArray(viewport)
3176
+ ? viewport as Record<string, unknown>
3177
+ : {};
3178
+ const viewportWidth = typeof viewportRecord.width === 'number' && Number.isFinite(viewportRecord.width) ? viewportRecord.width : undefined;
3179
+ const viewportHeight = typeof viewportRecord.height === 'number' && Number.isFinite(viewportRecord.height) ? viewportRecord.height : undefined;
3180
+ if (viewportWidth === undefined || viewportHeight === undefined) {
3181
+ throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_GEOMETRY_UNAVAILABLE', 'Top-level viewport dimensions are not finite for frame grounding.', { retryable: true });
3182
+ }
3183
+ return elements.map((entry) => {
3184
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return entry;
3185
+ const record = entry as Record<string, unknown>;
3186
+ const bounds = record.bounds && typeof record.bounds === 'object' && !Array.isArray(record.bounds)
3187
+ ? record.bounds as Record<string, unknown>
3188
+ : {};
3189
+ const center = record.center && typeof record.center === 'object' && !Array.isArray(record.center)
3190
+ ? record.center as Record<string, unknown>
3191
+ : {};
3192
+ const required = (value: unknown, field: string): number => {
3193
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
3194
+ throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_GEOMETRY_UNAVAILABLE', `Frame grounding field ${field} is not finite.`, { retryable: true });
3195
+ }
3196
+ return value;
3197
+ };
3198
+ const x = required(bounds.x, 'bounds.x');
3199
+ const y = required(bounds.y, 'bounds.y');
3200
+ const width = required(bounds.width, 'bounds.width');
3201
+ const height = required(bounds.height, 'bounds.height');
3202
+ const right = required(bounds.right, 'bounds.right');
3203
+ const bottom = required(bounds.bottom, 'bounds.bottom');
3204
+ const centerX = required(center.x, 'center.x');
3205
+ const centerY = required(center.y, 'center.y');
3206
+ const translatedX = x + offset.x;
3207
+ const translatedY = y + offset.y;
3208
+ const translatedRight = right + offset.x;
3209
+ const translatedBottom = bottom + offset.y;
3210
+ const frameLocalInViewport = record.inViewport === true;
3211
+ const topLevelInViewport = translatedRight > 0
3212
+ && translatedBottom > 0
3213
+ && translatedX < viewportWidth
3214
+ && translatedY < viewportHeight;
3215
+ return {
3216
+ ...record,
3217
+ bounds: {
3218
+ x: translatedX,
3219
+ y: translatedY,
3220
+ width,
3221
+ height,
3222
+ right: translatedRight,
3223
+ bottom: translatedBottom,
3224
+ },
3225
+ center: { x: centerX + offset.x, y: centerY + offset.y },
3226
+ frameLocalInViewport,
3227
+ topLevelInViewport,
3228
+ inViewport: frameLocalInViewport && topLevelInViewport,
3229
+ };
3230
+ });
3231
+ }
3232
+
3233
+ async function extractText(scope: BrowserEvaluationScope, selector: string | undefined, maxChars: number): Promise<Record<string, unknown>> {
3234
+ const raw = await scope.evaluate<string>(scriptText(selector));
3235
+ return truncateText(raw, maxChars);
3236
+ }
3237
+
3238
+ function trustedInputGuardScript(selector: string): string {
3239
+ const encodedSelector = JSON.stringify(selector);
3240
+ return `(() => {
3241
+ let element;
3242
+ try { element = document.querySelector(${encodedSelector}); }
3243
+ catch (error) { return { trustedInputGuardVersion: 1, found: false, selectorError: String(error) }; }
3244
+ if (!element) return { trustedInputGuardVersion: 1, found: false };
3245
+ const rect = element.getBoundingClientRect();
3246
+ const style = typeof getComputedStyle === 'function' ? getComputedStyle(element) : undefined;
3247
+ const visible = Number(rect.width || 0) > 0
3248
+ && Number(rect.height || 0) > 0
3249
+ && style?.display !== 'none'
3250
+ && style?.visibility !== 'hidden'
3251
+ && Number(style?.opacity ?? 1) !== 0;
3252
+ return {
3253
+ trustedInputGuardVersion: 1,
3254
+ found: true,
3255
+ visible,
3256
+ bounds: {
3257
+ x: Number(rect.left || 0), y: Number(rect.top || 0),
3258
+ width: Number(rect.width || 0), height: Number(rect.height || 0),
3259
+ right: Number(rect.right || 0), bottom: Number(rect.bottom || 0),
3260
+ },
3261
+ };
3262
+ })()`;
3263
+ }
3264
+
3265
+ function verifyStateObservationScript(selector: string | undefined, textContains: string | undefined, maxChars: number): string {
3266
+ const payload = JSON.stringify({ selector: selector ?? null, textContains: textContains ?? null, maxChars });
3267
+ return `(() => {
3268
+ const args = ${payload};
3269
+ const element = args.selector ? document.querySelector(args.selector) : null;
3270
+ const textTarget = element || document.body || document.documentElement;
3271
+ const rawText = String(textTarget?.innerText || textTarget?.textContent || '');
3272
+ let visible;
3273
+ if (args.selector) {
3274
+ if (!element) visible = false;
3275
+ else {
3276
+ const rect = element.getBoundingClientRect();
3277
+ const style = typeof getComputedStyle === 'function' ? getComputedStyle(element) : undefined;
3278
+ visible = Number(rect.width || 0) > 0
3279
+ && Number(rect.height || 0) > 0
3280
+ && style?.display !== 'none'
3281
+ && style?.visibility !== 'hidden'
3282
+ && Number(style?.opacity ?? 1) !== 0;
3283
+ }
3284
+ }
3285
+ return {
3286
+ verificationVersion: 1,
3287
+ selectorExists: args.selector ? Boolean(element) : undefined,
3288
+ visible,
3289
+ textContains: args.textContains === null ? undefined : rawText.includes(args.textContains),
3290
+ textSample: rawText.slice(0, args.maxChars),
3291
+ textLength: rawText.length,
3292
+ truncated: rawText.length > args.maxChars,
3293
+ };
3294
+ })()`;
1524
3295
  }
1525
3296
 
1526
- async function readPageIdentity(page: PageLike, connection?: BrowserConnectionSummary): Promise<{ url: string; title: string }> {
3297
+ async function readPageIdentity(page: PageLike, connection?: BrowserConnectionSummary, expectedUrl?: string): Promise<{ url: string; title: string }> {
3298
+ const normalizeObservedUrl = (value: unknown): string => {
3299
+ const internalProduct = browserInternalUrlProduct(value);
3300
+ if (connection?.provider === 'macos-apple-events' && connection.tab?.ownership === 'user_owned') {
3301
+ if (connection.browserProduct && expectedUrl && explicitNativeAdoptionUrlsMatch(expectedUrl, value, connection.browserProduct)) {
3302
+ return normalizedExplicitNativeAdoptionUrl(expectedUrl);
3303
+ }
3304
+ if (internalProduct && internalProduct === connection.browserProduct) {
3305
+ return normalizedExplicitNativeAdoptionUrl(value);
3306
+ }
3307
+ }
3308
+ return normalizedUrl(value);
3309
+ };
1527
3310
  if (connection?.provider === 'macos-apple-events' && typeof page.identity === 'function') {
1528
3311
  const identity = await page.identity();
1529
- return { url: normalizedUrl(identity.url), title: identity.title };
3312
+ return { url: normalizeObservedUrl(identity.url), title: identity.title };
1530
3313
  }
1531
- return { url: normalizedUrl(page.url()), title: await page.title() };
3314
+ return { url: normalizeObservedUrl(page.url()), title: await page.title() };
1532
3315
  }
1533
3316
 
1534
3317
  async function finalizeInteractiveAction(
@@ -1542,13 +3325,12 @@ async function finalizeInteractiveAction(
1542
3325
  extra: Record<string, unknown> = {},
1543
3326
  ): Promise<Record<string, unknown>> {
1544
3327
  const warnings: string[] = [];
1545
- const identity = await readPageIdentity(page, connection);
3328
+ const identity = await readPageIdentity(page, connection, target.url);
1546
3329
  const title = identity.title;
1547
3330
  const pageUrl = identity.url;
1548
- const session = sessionFromPage(target, pageUrl, title, connection);
1549
- saveSession(repoRoot, session);
3331
+ const session = saveSession(repoRoot, sessionFromPage(target, pageUrl, title, connection));
1550
3332
  let screenshot: BrowserActionScreenshot | undefined;
1551
- const silentNativeEvidence = connection.provider === 'macos-apple-events' && connection.tab?.ownership === 'plugin_owned';
3333
+ const silentNativeEvidence = connection.provider === 'macos-apple-events';
1552
3334
  if (!silentNativeEvidence) {
1553
3335
  try {
1554
3336
  screenshot = await captureActionScreenshot(page, repoRoot, actionId, session.sessionId, session.url);
@@ -1601,7 +3383,7 @@ function capabilities(): AssistantPluginCapability[] {
1601
3383
  title: 'Browser Sessions',
1602
3384
  description: 'Create, list, reuse, and close persistent browser sessions.',
1603
3385
  scopes: ['browser.read', 'browser.profile'],
1604
- actions: ['create_session', 'list_sessions', 'close_session', 'close_page', 'clear_session'],
3386
+ actions: ['create_session', 'list_sessions', 'reconcile_sessions', 'close_session', 'close_page', 'clear_session'],
1605
3387
  },
1606
3388
  {
1607
3389
  capabilityId: 'browser-human-handoff',
@@ -1617,7 +3399,7 @@ function capabilities(): AssistantPluginCapability[] {
1617
3399
  scopes: ['browser.read', 'browser.profile'],
1618
3400
  actions: [
1619
3401
  'open_page', 'navigate', 'reload', 'go_back', 'wait_for_load_state',
1620
- 'get_text', 'get_html', 'query_selector', 'query_all', 'get_attribute',
3402
+ 'get_text', 'get_html', 'query_selector', 'query_all', 'get_attribute', 'list_frames', 'verify_state',
1621
3403
  'screenshot', 'extract_links', 'extract_tables', 'extract_forms', 'snapshot_interactive',
1622
3404
  'get_console_errors', 'get_failed_requests',
1623
3405
  ],
@@ -1628,8 +3410,8 @@ function capabilities(): AssistantPluginCapability[] {
1628
3410
  description: 'Perform explicit form and pointer interactions on HTTP(S) pages through the persistent Playwright profile.',
1629
3411
  scopes: ['browser.interact', 'browser.profile'],
1630
3412
  actions: [
1631
- 'click', 'double_click', 'hover', 'focus', 'type', 'fill', 'select_option',
1632
- 'check', 'uncheck', 'press', 'keyboard_shortcut', 'wait_for_selector', 'attach_local_file', 'await_file_transfer',
3413
+ 'activate_page', 'click', 'click_text', 'double_click', 'hover', 'focus', 'type', 'fill', 'select_option',
3414
+ 'check', 'uncheck', 'press', 'keyboard_shortcut', 'trusted_input', 'dispatch_event', 'wait_for_selector', 'attach_local_file', 'await_file_transfer',
1633
3415
  ],
1634
3416
  },
1635
3417
  ];
@@ -1644,6 +3426,10 @@ function sessionTargetSchema(extra: Record<string, unknown> = {}, required: stri
1644
3426
  wait_until: { type: 'string', enum: ['load', 'domcontentloaded', 'networkidle'] },
1645
3427
  timeout_ms: { type: 'number' },
1646
3428
  retries: { type: 'number' },
3429
+ browser_mode: { type: 'string', enum: ['attach_preferred', 'managed_persistent', 'isolated'] },
3430
+ cdp_attach_fallback: { type: 'string', enum: ['managed_persistent', 'fail_closed'] },
3431
+ native_attach_mode: { type: 'string', enum: ['auto', 'disabled'] },
3432
+ native_browser_candidates: { type: 'array', items: { type: 'string', enum: ['vivaldi', 'chrome'] }, maxItems: 2 },
1647
3433
  ...extra,
1648
3434
  },
1649
3435
  ...(required.length > 0 ? { required } : {}),
@@ -1651,6 +3437,11 @@ function sessionTargetSchema(extra: Record<string, unknown> = {}, required: stri
1651
3437
  };
1652
3438
  }
1653
3439
 
3440
+ const frameScopeProperties = {
3441
+ frame_url: { type: 'string' },
3442
+ frame_name: { type: 'string' },
3443
+ };
3444
+
1654
3445
  function interactSchema(extra: Record<string, unknown>, required: string[]): Record<string, unknown> {
1655
3446
  return sessionTargetSchema({
1656
3447
  post_action_wait_ms: { type: 'number' },
@@ -1667,7 +3458,7 @@ function actions(): AssistantPluginActionDescriptor[] {
1667
3458
  { resource: 'remote' as const, mode: 'exclusive' as const },
1668
3459
  { resource: 'repo-state' as const, mode: 'write' as const },
1669
3460
  ];
1670
- return [
3461
+ const descriptors: AssistantPluginActionDescriptor[] = [
1671
3462
  {
1672
3463
  actionId: 'configure',
1673
3464
  title: 'Configure browser plugin',
@@ -1710,10 +3501,17 @@ function actions(): AssistantPluginActionDescriptor[] {
1710
3501
  {
1711
3502
  actionId: 'create_session',
1712
3503
  title: 'Create browser session',
1713
- description: 'Open an HTTP(S) URL and persist a reusable session id.',
3504
+ description: 'Open an HTTP(S) URL or explicitly adopt the matching frontmost native tab, then persist a reusable session id.',
1714
3505
  readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
1715
3506
  scopes: ['browser.read', 'browser.profile'], resourceClaims: [{ resource: 'repo-state', mode: 'write' }, ...readRemote],
1716
- argumentsSchema: sessionTargetSchema({ extract_text: { type: 'boolean' }, max_chars: { type: 'number' } }, ['url']),
3507
+ argumentsSchema: sessionTargetSchema({
3508
+ extract_text: { type: 'boolean' },
3509
+ max_chars: { type: 'number' },
3510
+ native_browser_product: { type: 'string', enum: ['chrome', 'vivaldi'] },
3511
+ native_window_id: { type: 'string' },
3512
+ native_tab_id: { type: 'string' },
3513
+ native_active_tab: { type: 'boolean' },
3514
+ }, ['url']),
1717
3515
  },
1718
3516
  {
1719
3517
  actionId: 'list_sessions',
@@ -1721,6 +3519,21 @@ function actions(): AssistantPluginActionDescriptor[] {
1721
3519
  description: 'List saved browser session metadata without secrets or cookies.',
1722
3520
  readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 10_000, cancellable: true, idempotent: true,
1723
3521
  scopes: ['browser.read', 'browser.profile'], resourceClaims: [{ resource: 'repo-state', mode: 'read' }],
3522
+ argumentsSchema: {
3523
+ type: 'object',
3524
+ properties: {
3525
+ limit: { type: 'number', minimum: 1, maximum: MAX_BROWSER_SESSION_LIST_LIMIT, default: DEFAULT_BROWSER_SESSION_LIST_LIMIT },
3526
+ cursor: { type: 'string' },
3527
+ },
3528
+ additionalProperties: false,
3529
+ },
3530
+ },
3531
+ {
3532
+ actionId: 'reconcile_sessions',
3533
+ title: 'Reconcile browser sessions',
3534
+ description: 'Prune only saved managed-session metadata whose exact Runtime-bound page is positively proven gone. Never removes profiles, cookies, or unverified native sessions.',
3535
+ readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 15_000, cancellable: true, idempotent: true,
3536
+ scopes: ['browser.read', 'browser.profile'], resourceClaims: [{ resource: 'repo-state', mode: 'write' }],
1724
3537
  argumentsSchema: { type: 'object', properties: {}, additionalProperties: false },
1725
3538
  },
1726
3539
  {
@@ -1787,24 +3600,24 @@ function actions(): AssistantPluginActionDescriptor[] {
1787
3600
  actionId: 'navigate',
1788
3601
  title: 'Navigate',
1789
3602
  description: 'Navigate an existing session or open a new page to an HTTP(S) URL.',
1790
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
1791
- scopes: ['browser.read'], resourceClaims: readRemote,
3603
+ readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
3604
+ scopes: ['browser.read', 'browser.interact', 'browser.profile'], resourceClaims: writeRemote,
1792
3605
  argumentsSchema: sessionTargetSchema({}, ['url']),
1793
3606
  },
1794
3607
  {
1795
3608
  actionId: 'reload',
1796
3609
  title: 'Reload page',
1797
3610
  description: 'Reload the current page for a session.',
1798
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
1799
- scopes: ['browser.read'], resourceClaims: readRemote,
3611
+ readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
3612
+ scopes: ['browser.read', 'browser.interact', 'browser.profile'], resourceClaims: writeRemote,
1800
3613
  argumentsSchema: sessionTargetSchema({}, ['session_id']),
1801
3614
  },
1802
3615
  {
1803
3616
  actionId: 'go_back',
1804
3617
  title: 'Go back',
1805
3618
  description: 'Navigate back in history for a session.',
1806
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
1807
- scopes: ['browser.read'], resourceClaims: readRemote,
3619
+ readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
3620
+ scopes: ['browser.read', 'browser.interact', 'browser.profile'], resourceClaims: writeRemote,
1808
3621
  argumentsSchema: sessionTargetSchema({}, ['session_id']),
1809
3622
  },
1810
3623
  {
@@ -1821,7 +3634,7 @@ function actions(): AssistantPluginActionDescriptor[] {
1821
3634
  description: 'Extract text from a URL or saved browser session.',
1822
3635
  readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
1823
3636
  scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
1824
- argumentsSchema: sessionTargetSchema({ selector: { type: 'string' }, max_chars: { type: 'number' } }),
3637
+ argumentsSchema: sessionTargetSchema({ selector: { type: 'string' }, max_chars: { type: 'number' }, ...frameScopeProperties }),
1825
3638
  },
1826
3639
  {
1827
3640
  actionId: 'get_html',
@@ -1829,7 +3642,7 @@ function actions(): AssistantPluginActionDescriptor[] {
1829
3642
  description: 'Extract HTML for the page or a selector (bounded).',
1830
3643
  readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
1831
3644
  scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
1832
- argumentsSchema: sessionTargetSchema({ selector: { type: 'string' }, max_chars: { type: 'number' } }),
3645
+ argumentsSchema: sessionTargetSchema({ selector: { type: 'string' }, max_chars: { type: 'number' }, ...frameScopeProperties }),
1833
3646
  },
1834
3647
  {
1835
3648
  actionId: 'query_selector',
@@ -1837,7 +3650,7 @@ function actions(): AssistantPluginActionDescriptor[] {
1837
3650
  description: 'Return the first matching element summary with a stable selector hint.',
1838
3651
  readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
1839
3652
  scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
1840
- argumentsSchema: sessionTargetSchema({ selector: { type: 'string' } }, ['selector']),
3653
+ argumentsSchema: sessionTargetSchema({ selector: { type: 'string' }, ...frameScopeProperties }, ['selector']),
1841
3654
  },
1842
3655
  {
1843
3656
  actionId: 'query_all',
@@ -1845,7 +3658,7 @@ function actions(): AssistantPluginActionDescriptor[] {
1845
3658
  description: 'Return matching element summaries (bounded).',
1846
3659
  readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
1847
3660
  scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
1848
- argumentsSchema: sessionTargetSchema({ selector: { type: 'string' }, limit: { type: 'number' } }, ['selector']),
3661
+ argumentsSchema: sessionTargetSchema({ selector: { type: 'string' }, limit: { type: 'number' }, ...frameScopeProperties }, ['selector']),
1849
3662
  },
1850
3663
  {
1851
3664
  actionId: 'get_attribute',
@@ -1853,7 +3666,31 @@ function actions(): AssistantPluginActionDescriptor[] {
1853
3666
  description: 'Read one attribute from a selector.',
1854
3667
  readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
1855
3668
  scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
1856
- argumentsSchema: sessionTargetSchema({ selector: { type: 'string' }, attribute: { type: 'string' } }, ['selector', 'attribute']),
3669
+ argumentsSchema: sessionTargetSchema({ selector: { type: 'string' }, attribute: { type: 'string' }, ...frameScopeProperties }, ['selector', 'attribute']),
3670
+ },
3671
+ {
3672
+ actionId: 'list_frames',
3673
+ title: 'List page frames',
3674
+ description: 'List bounded Playwright/CDP frame identities for explicit frame-scoped DOM observation.',
3675
+ readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
3676
+ scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
3677
+ argumentsSchema: sessionTargetSchema({ limit: { type: 'number', minimum: 1, maximum: 100 } }),
3678
+ },
3679
+ {
3680
+ actionId: 'verify_state',
3681
+ title: 'Verify browser state',
3682
+ description: 'Observe URL, selector existence/visibility, and bounded text criteria in one read-only call for post-action verification.',
3683
+ readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
3684
+ scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
3685
+ argumentsSchema: sessionTargetSchema({
3686
+ expected_url: { type: 'string' },
3687
+ url_contains: { type: 'string' },
3688
+ selector: { type: 'string' },
3689
+ require_visible: { type: 'boolean' },
3690
+ text_contains: { type: 'string', maxLength: 10000 },
3691
+ max_chars: { type: 'number', minimum: 1, maximum: 100000 },
3692
+ ...frameScopeProperties,
3693
+ }, ['session_id']),
1857
3694
  },
1858
3695
  {
1859
3696
  actionId: 'screenshot',
@@ -1890,10 +3727,10 @@ function actions(): AssistantPluginActionDescriptor[] {
1890
3727
  {
1891
3728
  actionId: 'snapshot_interactive',
1892
3729
  title: 'Snapshot interactive elements',
1893
- description: 'Snapshot buttons/inputs/links with stable selector hints.',
3730
+ description: 'Snapshot interactive elements with stable selector hints plus CSS-pixel viewport geometry for screenshot-to-input grounding.',
1894
3731
  readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
1895
3732
  scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
1896
- argumentsSchema: sessionTargetSchema({ limit: { type: 'number' } }),
3733
+ argumentsSchema: sessionTargetSchema({ limit: { type: 'number' }, ...frameScopeProperties }),
1897
3734
  },
1898
3735
  {
1899
3736
  actionId: 'get_console_errors',
@@ -1911,6 +3748,14 @@ function actions(): AssistantPluginActionDescriptor[] {
1911
3748
  scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
1912
3749
  argumentsSchema: sessionTargetSchema({}),
1913
3750
  },
3751
+ {
3752
+ actionId: 'activate_page',
3753
+ title: 'Activate browser page',
3754
+ description: 'Bring the exact saved browser page/tab to the foreground without opening or replacing a tab.',
3755
+ readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
3756
+ scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
3757
+ argumentsSchema: interactSchema({}, []),
3758
+ },
1914
3759
  {
1915
3760
  actionId: 'click',
1916
3761
  title: 'Click element',
@@ -1919,6 +3764,14 @@ function actions(): AssistantPluginActionDescriptor[] {
1919
3764
  scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
1920
3765
  argumentsSchema: interactSchema({ selector: { type: 'string' } }, ['selector']),
1921
3766
  },
3767
+ {
3768
+ actionId: 'click_text',
3769
+ title: 'Click exact visible text',
3770
+ description: 'Click the smallest visible standard DOM element whose normalized text exactly matches the requested text. Closed shadow roots are intentionally not traversed.',
3771
+ readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
3772
+ scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
3773
+ argumentsSchema: interactSchema({ text: { type: 'string', minLength: 1, maxLength: 200 } }, ['text']),
3774
+ },
1922
3775
  {
1923
3776
  actionId: 'double_click',
1924
3777
  title: 'Double-click element',
@@ -1999,6 +3852,39 @@ function actions(): AssistantPluginActionDescriptor[] {
1999
3852
  scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
2000
3853
  argumentsSchema: interactSchema({ key: { type: 'string' } }, ['key']),
2001
3854
  },
3855
+ {
3856
+ actionId: 'trusted_input',
3857
+ title: 'Trusted browser input',
3858
+ description: 'Send bounded mouse, wheel, drag, key, or text input after authorization. Playwright/CDP uses page input primitives; native macOS sessions require the exact saved tab to already be foreground/active and use the stable Desktop Operator input broker without activating another tab.',
3859
+ readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
3860
+ scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
3861
+ argumentsSchema: interactSchema({
3862
+ kind: { type: 'string', enum: ['click', 'move', 'wheel', 'drag', 'key', 'text'] },
3863
+ x: { type: 'number', minimum: 0, maximum: 100000 },
3864
+ y: { type: 'number', minimum: 0, maximum: 100000 },
3865
+ from_x: { type: 'number', minimum: 0, maximum: 100000 },
3866
+ from_y: { type: 'number', minimum: 0, maximum: 100000 },
3867
+ to_x: { type: 'number', minimum: 0, maximum: 100000 },
3868
+ to_y: { type: 'number', minimum: 0, maximum: 100000 },
3869
+ delta_x: { type: 'number', minimum: -100000, maximum: 100000 },
3870
+ delta_y: { type: 'number', minimum: -100000, maximum: 100000 },
3871
+ button: { type: 'string', enum: ['left', 'middle', 'right'] },
3872
+ click_count: { type: 'number', minimum: 1, maximum: 3 },
3873
+ steps: { type: 'number', minimum: 1, maximum: 100 },
3874
+ key: { type: 'string', minLength: 1, maxLength: 100 },
3875
+ text: { type: 'string', maxLength: 10000 },
3876
+ guard_selector: { type: 'string' },
3877
+ ...frameScopeProperties,
3878
+ }, ['kind']),
3879
+ },
3880
+ {
3881
+ actionId: 'dispatch_event',
3882
+ title: 'Dispatch DOM event',
3883
+ description: 'Dispatch one named bubbling/composed DOM CustomEvent on an explicit selector after authorization. No arbitrary JavaScript payload is accepted.',
3884
+ readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
3885
+ scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
3886
+ argumentsSchema: interactSchema({ selector: { type: 'string' }, event: { type: 'string', minLength: 1, maxLength: 64 } }, ['selector', 'event']),
3887
+ },
2002
3888
  {
2003
3889
  actionId: 'wait_for_selector',
2004
3890
  title: 'Wait for selector',
@@ -2012,11 +3898,15 @@ function actions(): AssistantPluginActionDescriptor[] {
2012
3898
  },
2013
3899
  {
2014
3900
  actionId: 'attach_local_file',
2015
- title: 'Attach local file',
2016
- description: 'Set an input[type=file] path for an allowed local file after authorization. Never auto-opens executables.',
3901
+ title: 'Attach local file(s)',
3902
+ description: 'Set one or more allowed local files on input[type=file] after authorization. file_path remains backward-compatible; file_paths supports multiple selection. Never auto-opens executables.',
2017
3903
  readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
2018
3904
  scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
2019
- argumentsSchema: interactSchema({ selector: { type: 'string' }, file_path: { type: 'string' } }, ['selector', 'file_path']),
3905
+ argumentsSchema: interactSchema({
3906
+ selector: { type: 'string' },
3907
+ file_path: { type: 'string' },
3908
+ file_paths: { type: 'array', minItems: 1, maxItems: 32, items: { type: 'string' } },
3909
+ }, ['selector']),
2020
3910
  },
2021
3911
  {
2022
3912
  actionId: 'await_file_transfer',
@@ -2035,6 +3925,21 @@ function actions(): AssistantPluginActionDescriptor[] {
2035
3925
  argumentsSchema: { type: 'object', properties: { session_id: { type: 'string' } }, required: ['session_id'], additionalProperties: false },
2036
3926
  },
2037
3927
  ];
3928
+ const foregroundRequired = new Set(['activate_page', 'request_human_handoff']);
3929
+ const foregroundPossible = new Set([
3930
+ 'create_session', 'open_page', 'navigate', 'reload', 'go_back', 'resolve_handoff',
3931
+ 'click', 'click_text', 'double_click', 'hover', 'focus', 'type', 'fill', 'select_option',
3932
+ 'check', 'uncheck', 'press', 'keyboard_shortcut', 'trusted_input', 'dispatch_event',
3933
+ 'wait_for_selector', 'attach_local_file', 'await_file_transfer',
3934
+ ]);
3935
+ return descriptors.map((descriptor) => ({
3936
+ ...descriptor,
3937
+ foregroundEffect: foregroundRequired.has(descriptor.actionId)
3938
+ ? 'required'
3939
+ : foregroundPossible.has(descriptor.actionId)
3940
+ ? 'possible'
3941
+ : 'none',
3942
+ }));
2038
3943
  }
2039
3944
 
2040
3945
  function browserUserFacingStatus(config: BrowserPluginConfig, ready: boolean, sessionCount = 0): string {
@@ -2045,10 +3950,11 @@ function browserUserFacingStatus(config: BrowserPluginConfig, ready: boolean, se
2045
3950
  }
2046
3951
 
2047
3952
  function health(config: BrowserPluginConfig, repoRoot?: string): AssistantPluginHealth {
2048
- const dependencyReady = runtimeHooks.moduleAvailable('playwright');
3953
+ const dependencyReady = runtimeHooks.moduleAvailable('playwright', repoRoot);
2049
3954
  const configErrors = validateConfig(config);
2050
3955
  const warnings = configWarnings(config);
2051
- const sessionCount = repoRoot ? listSavedSessions(repoRoot).length : 0;
3956
+ const authority = currentBrowserSessionAuthorityContext();
3957
+ const savedSessionCount = repoRoot && authority ? listSavedBrowserSessions(repoRoot).length : 0;
2052
3958
  const activeHandoffCount = repoRoot
2053
3959
  ? listBrowserHandoffs(repoRoot).filter((entry) => ['starting', 'waiting_for_user', 'closing'].includes(entry.status)).length
2054
3960
  : 0;
@@ -2067,7 +3973,9 @@ function health(config: BrowserPluginConfig, repoRoot?: string): AssistantPlugin
2067
3973
  nativeBrowserCandidates: config.nativeBrowserCandidates,
2068
3974
  nativeAttachSupported: macOsActiveBrowserAttachSupported(),
2069
3975
  windowMode: 'visible' as const,
2070
- sessionCount,
3976
+ sessionCount: savedSessionCount,
3977
+ sessionCountSemantics: authority ? 'controller_authority' as const : 'controller_authority_unavailable' as const,
3978
+ savedSessionCount,
2071
3979
  activeHandoffCount,
2072
3980
  humanHandoffSupported: true,
2073
3981
  artifactsAvailable: true,
@@ -2098,14 +4006,9 @@ function health(config: BrowserPluginConfig, repoRoot?: string): AssistantPlugin
2098
4006
  const managedRouteReady = dependencyReady
2099
4007
  && (config.browserMode !== 'attach_preferred' || config.cdpAttachFallback === 'managed_persistent');
2100
4008
  const nativeRouteReady = nativeAttachConfigured && nativeObservation?.ready === true;
2101
- const nativeRouteUnverified = nativeAttachConfigured
2102
- && cdpEndpoints(config).length === 0
2103
- && config.cdpAttachFallback === 'fail_closed'
2104
- && nativeObservation === undefined;
2105
- const nativeRouteFailed = nativeAttachConfigured
2106
- && cdpEndpoints(config).length === 0
2107
- && config.cdpAttachFallback === 'fail_closed'
2108
- && nativeObservation?.ready === false;
4009
+ const nativeOnlyRoute = nativeAttachConfigured && !cdpRouteReady && !managedRouteReady;
4010
+ const nativeRouteUnverified = nativeOnlyRoute && nativeObservation === undefined;
4011
+ const nativeRouteFailed = nativeOnlyRoute && nativeObservation?.ready === false;
2109
4012
  if (nativeRouteUnverified || nativeRouteFailed) {
2110
4013
  const nativeWarnings = nativeRouteFailed
2111
4014
  ? nativeObservation?.attempts.map((attempt) => `${attempt.appName}: ${attempt.error ?? attempt.status}`) ?? []
@@ -2120,7 +4023,7 @@ function health(config: BrowserPluginConfig, repoRoot?: string): AssistantPlugin
2120
4023
  details: {
2121
4024
  ...baseDetails,
2122
4025
  nativeAttachObservation: nativeObservation,
2123
- provider: 'macos-active-browser-fail-closed',
4026
+ provider: 'macos-active-browser',
2124
4027
  userFacingStatus: 'not ready',
2125
4028
  },
2126
4029
  };
@@ -2166,7 +4069,7 @@ function health(config: BrowserPluginConfig, repoRoot?: string): AssistantPlugin
2166
4069
  provider: config.browserMode === 'attach_preferred'
2167
4070
  ? 'cdp-or-macos-active-browser-or-persistent-context'
2168
4071
  : 'playwright-persistent-context',
2169
- userFacingStatus: browserUserFacingStatus(config, true, sessionCount),
4072
+ userFacingStatus: browserUserFacingStatus(config, true, savedSessionCount),
2170
4073
  },
2171
4074
  };
2172
4075
  }
@@ -2186,7 +4089,7 @@ export function buildBrowserPluginManifest(previousRevision = 0, previousUpdated
2186
4089
  authority: {
2187
4090
  strategy: 'derived',
2188
4091
  duplicateStateAllowed: false,
2189
- sourceOfTruth: ['repo-local:.forge/plugins/browser.json', 'repo-local:.forge/browser/'],
4092
+ sourceOfTruth: ['repo-local:.forge/plugins/browser.json', 'controller-home:sqlite/browser_session', 'repo-local:.forge/browser/{screenshots,downloads,diagnostics}'],
2190
4093
  },
2191
4094
  enabled: config.enabled,
2192
4095
  lifecycle: {
@@ -2207,8 +4110,25 @@ export function buildBrowserPluginManifest(previousRevision = 0, previousUpdated
2207
4110
  };
2208
4111
  }
2209
4112
 
2210
- export async function executeBrowserPluginAction(input: AssistantPluginActionExecutionInput): Promise<Record<string, unknown>> {
2211
- const current = loadConfig(input.repoRoot);
4113
+ const NODE_BRIDGE_PRE_ACTION_FAILURE_CODES = new Set([
4114
+ 'PLUGIN_BROWSER_NODE_UNAVAILABLE',
4115
+ 'PLUGIN_BROWSER_NODE_HOST_UNAVAILABLE',
4116
+ 'PLUGIN_BROWSER_NODE_REQUEST_TOO_LARGE',
4117
+ 'PLUGIN_BROWSER_NODE_START_FAILED',
4118
+ ]);
4119
+
4120
+ function browserRuntimeKey(input: AssistantPluginActionExecutionInput): string {
4121
+ return `${input.controllerHome}:${input.repoId}`;
4122
+ }
4123
+
4124
+ async function executeBrowserPluginActionInternal(
4125
+ input: AssistantPluginActionExecutionInput,
4126
+ runtimeAlreadyRouted = false,
4127
+ ): Promise<Record<string, unknown>> {
4128
+ const persisted = loadConfig(input.repoRoot);
4129
+ const actionSessionId = input.actionId === 'configure' ? undefined : stringValue(input.args.session_id);
4130
+ const actionSession = actionSessionId ? findSession(input.repoRoot, actionSessionId) : undefined;
4131
+ const current = input.actionId === 'configure' ? persisted : effectiveBrowserActionConfig(persisted, input.args, actionSession);
2212
4132
  if (!current.enabled && input.actionId !== 'configure') {
2213
4133
  throw new AssistantPluginError('PLUGIN_DISABLED', 'Browser plugin is disabled.', { retryable: false });
2214
4134
  }
@@ -2218,8 +4138,54 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2218
4138
  throw new AssistantPluginError('PLUGIN_CONFIGURATION_INVALID', configErrors[0], { retryable: false });
2219
4139
  }
2220
4140
  }
2221
- if (shouldUseBrowserNodeBridge(input.actionId, current.browserMode, runtimeHooksCustomized, cdpEndpoints(current).length > 0)) {
2222
- return await executeBrowserActionThroughNode(input);
4141
+ if (!runtimeAlreadyRouted) {
4142
+ const runtimeKey = browserRuntimeKey(input);
4143
+ const nodeBridgeEligible = shouldUseBrowserNodeBridge(
4144
+ input.actionId,
4145
+ current.browserMode,
4146
+ runtimeHooksCustomized,
4147
+ cdpEndpoints(current).length > 0,
4148
+ );
4149
+ const result = await executeBrowserRuntimeAction({
4150
+ runtimeKey,
4151
+ input,
4152
+ providers: [
4153
+ ...(nodeBridgeEligible ? [{
4154
+ providerId: 'browser.node_bridge',
4155
+ capabilities: ALL_BROWSER_PROVIDER_CAPABILITIES.filter((capability) => capability !== 'browser.persistent_handle'),
4156
+ foreground: 'none' as const,
4157
+ verifiesPostconditions: true,
4158
+ persistentHandle: false,
4159
+ priority: 10,
4160
+ supportsInput: () => true,
4161
+ execute: async (routedInput: AssistantPluginActionExecutionInput) => {
4162
+ try {
4163
+ return await executeBrowserActionThroughNode(routedInput);
4164
+ } catch (error) {
4165
+ if (error instanceof AssistantPluginError && NODE_BRIDGE_PRE_ACTION_FAILURE_CODES.has(error.code)) {
4166
+ throw new BrowserProviderUnavailableBeforeActionError('browser.node_bridge', error.code);
4167
+ }
4168
+ throw error;
4169
+ }
4170
+ },
4171
+ }] : []),
4172
+ {
4173
+ providerId: 'browser.local',
4174
+ capabilities: ALL_BROWSER_PROVIDER_CAPABILITIES,
4175
+ foreground: 'none' as const,
4176
+ verifiesPostconditions: true,
4177
+ persistentHandle: true,
4178
+ priority: 20,
4179
+ supportsInput: () => true,
4180
+ execute: async (routedInput: AssistantPluginActionExecutionInput) => executeBrowserPluginActionInternal(routedInput, true),
4181
+ },
4182
+ ],
4183
+ });
4184
+ if (input.actionId === 'configure') {
4185
+ invalidateBrowserRuntime(runtimeKey, 'configuration_changed');
4186
+ invalidateMacOsBrowserPageHandles();
4187
+ }
4188
+ return result;
2223
4189
  }
2224
4190
  try {
2225
4191
  switch (input.actionId) {
@@ -2265,56 +4231,156 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2265
4231
  if (configErrors.length > 0) {
2266
4232
  throw new AssistantPluginError('PLUGIN_CONFIGURATION_INVALID', configErrors[0], { retryable: false });
2267
4233
  }
4234
+ await closeManagedContextsForRepo(input.repoRoot);
2268
4235
  return { config, health: health(config, input.repoRoot) };
2269
4236
  }
2270
- case 'list_sessions':
4237
+ case 'list_sessions': {
4238
+ const inventory = await inspectSavedSessions(input.repoRoot, current);
4239
+ const requestedLimit = Math.max(1, Math.min(Math.trunc(positiveNumber(input.args.limit, DEFAULT_BROWSER_SESSION_LIST_LIMIT)), MAX_BROWSER_SESSION_LIST_LIMIT));
4240
+ let offset = 0;
4241
+ const cursor = stringValue(input.args.cursor);
4242
+ if (cursor) {
4243
+ try {
4244
+ const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as { offset?: unknown };
4245
+ if (!Number.isInteger(parsed.offset) || Number(parsed.offset) < 0) throw new Error('invalid offset');
4246
+ offset = Number(parsed.offset);
4247
+ } catch {
4248
+ throw new AssistantPluginError('PLUGIN_BROWSER_SESSION_CURSOR_INVALID', 'Browser session cursor is invalid or expired.', { retryable: false });
4249
+ }
4250
+ }
4251
+ const page = inventory.sessions.slice(offset, offset + requestedLimit);
4252
+ const nextOffset = offset + page.length;
2271
4253
  return {
2272
4254
  provider: 'playwright',
2273
- sessions: listSavedSessions(input.repoRoot).map((session) => ({
4255
+ sessionCountSemantics: currentBrowserSessionAuthorityContext() ? 'controller_authority' : 'saved_metadata',
4256
+ scannedSessionCount: inventory.scannedSessionCount,
4257
+ savedSessionCount: inventory.savedSessionCount,
4258
+ liveManagedSessionCount: inventory.liveManagedSessionCount,
4259
+ liveNativeSessionCount: inventory.liveNativeSessionCount,
4260
+ unverifiedSessionCount: inventory.unverifiedSessionCount,
4261
+ deadManagedSessionCount: inventory.deadManagedSessionCount,
4262
+ deadNativeSessionCount: inventory.deadNativeSessionCount,
4263
+ limit: requestedLimit,
4264
+ totalCount: inventory.sessions.length,
4265
+ ...(nextOffset < inventory.sessions.length ? { nextCursor: Buffer.from(JSON.stringify({ offset: nextOffset }), 'utf8').toString('base64url') } : {}),
4266
+ sessions: page.map(({ session, liveness, evidence, cleanupError }) => ({
2274
4267
  sessionId: session.sessionId,
2275
4268
  url: session.url,
2276
4269
  title: session.title,
2277
4270
  createdAt: session.createdAt,
2278
4271
  updatedAt: session.updatedAt,
2279
4272
  browser: session.browser,
4273
+ liveness,
4274
+ livenessEvidence: evidence,
4275
+ ...(cleanupError ? { cleanupError } : {}),
2280
4276
  })),
2281
4277
  };
4278
+ }
4279
+ case 'reconcile_sessions': {
4280
+ assertBrowserSessionAvailable(input.repoRoot);
4281
+ const inventory = await inspectSavedSessions(input.repoRoot, current, { pruneDead: true });
4282
+ return {
4283
+ reconciled: true,
4284
+ sessionCountSemantics: 'saved_metadata',
4285
+ scannedSessionCount: inventory.scannedSessionCount,
4286
+ savedSessionCount: inventory.savedSessionCount,
4287
+ liveManagedSessionCount: inventory.liveManagedSessionCount,
4288
+ liveNativeSessionCount: inventory.liveNativeSessionCount,
4289
+ unverifiedSessionCount: inventory.unverifiedSessionCount,
4290
+ deadManagedSessionCount: inventory.deadManagedSessionCount,
4291
+ deadNativeSessionCount: inventory.deadNativeSessionCount,
4292
+ closedInvalidNativeSessionCount: inventory.closedInvalidNativeSessionCount,
4293
+ failedNativeCleanupCount: inventory.failedNativeCleanupCount,
4294
+ prunedSessionCount: inventory.prunedSessionCount,
4295
+ prunedSessionIds: inventory.sessions.filter((item) => item.pruned).map((item) => item.session.sessionId),
4296
+ };
4297
+ }
2282
4298
  case 'close_session':
2283
4299
  case 'close_page': {
2284
4300
  const sessionId = requiredString(input.args.session_id, 'session_id');
2285
4301
  assertBrowserSessionAvailable(input.repoRoot, sessionId);
2286
4302
  const session = findSession(input.repoRoot, sessionId);
2287
4303
  const tab = session?.browser?.tab;
2288
- if (session?.browser?.provider === 'macos-apple-events'
2289
- && session.browser.browserProduct
2290
- && tab?.ownership === 'plugin_owned'
2291
- && typeof tab.windowId === 'string'
2292
- && typeof tab.tabId === 'string') {
2293
- await closeMacOsBrowserOwnedTab(session.browser.browserProduct, { windowId: tab.windowId, tabId: tab.tabId }, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS)
2294
- .catch(() => undefined);
2295
- }
2296
- rmSync(sessionPath(input.repoRoot, sessionId), { force: true });
2297
- return { closed: true, sessionId, resourceClosed: tab?.ownership === 'plugin_owned' };
4304
+ const managedClosed = session ? await closeManagedSessionPage(input.repoRoot, current, session) : false;
4305
+ const nativeClose = session ? await closeTrackedNativeOwnedSession(session, current) : { resourceClosed: false, resourceAlreadyMissing: false };
4306
+ const closedSessionIds = session ? nativeOwnedAliasSessionIds(input.repoRoot, session) : [sessionId];
4307
+ for (const closedSessionId of closedSessionIds) removeSession(input.repoRoot, closedSessionId);
4308
+ return {
4309
+ closed: true, sessionId,
4310
+ resourceClosed: managedClosed || nativeClose.resourceClosed,
4311
+ ...(closedSessionIds.length > 1 ? { closedSessionIds } : {}),
4312
+ ...(nativeClose.resourceAlreadyMissing ? { resourceAlreadyMissing: true } : {}),
4313
+ ...(tab?.ownership === 'user_owned' ? { preservedUserOwnedTab: true } : {}),
4314
+ };
2298
4315
  }
2299
4316
  case 'clear_session': {
2300
4317
  assertBrowserSessionAvailable(input.repoRoot);
2301
4318
  const sessions = listSavedSessions(input.repoRoot);
4319
+ await closeManagedContextsForRepo(input.repoRoot, { strict: true });
4320
+ const failedSessionIds: string[] = [];
4321
+ const cleanupErrors: Array<{ sessionId: string; error: string }> = [];
4322
+ let removedCount = 0;
4323
+ let resourceClosedCount = 0;
4324
+ let resourceAlreadyMissingCount = 0;
2302
4325
  for (const session of sessions) {
2303
- const tab = session.browser?.tab;
2304
- if (session.browser?.provider === 'macos-apple-events'
2305
- && session.browser.browserProduct
2306
- && tab?.ownership === 'plugin_owned'
2307
- && typeof tab.windowId === 'string'
2308
- && typeof tab.tabId === 'string') {
2309
- await closeMacOsBrowserOwnedTab(session.browser.browserProduct, { windowId: tab.windowId, tabId: tab.tabId }, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS)
2310
- .catch(() => undefined);
4326
+ try {
4327
+ const nativeClose = await closeTrackedNativeOwnedSession(session, current);
4328
+ if (nativeClose.resourceClosed) resourceClosedCount += 1;
4329
+ if (nativeClose.resourceAlreadyMissing) resourceAlreadyMissingCount += 1;
4330
+ removeSession(input.repoRoot, session.sessionId);
4331
+ removedCount += 1;
4332
+ } catch (error) {
4333
+ failedSessionIds.push(session.sessionId);
4334
+ cleanupErrors.push({ sessionId: session.sessionId, error: error instanceof Error ? error.message : String(error) });
2311
4335
  }
2312
- rmSync(sessionPath(input.repoRoot, session.sessionId), { force: true });
2313
4336
  }
2314
- return { cleared: true, count: sessions.length };
4337
+ return {
4338
+ cleared: failedSessionIds.length === 0,
4339
+ count: removedCount,
4340
+ requestedCount: sessions.length,
4341
+ resourceClosedCount,
4342
+ resourceAlreadyMissingCount,
4343
+ failedSessionIds,
4344
+ cleanupErrors,
4345
+ };
2315
4346
  }
2316
4347
  case 'request_human_handoff': {
2317
4348
  const target = resolveActionTarget(input.repoRoot, input.args);
4349
+ const managedSession = target.existingSession;
4350
+ const managedTarget = managedSession ? await runtimeManagedPageForSession(input.repoRoot, current, managedSession) : undefined;
4351
+ if (managedSession && managedTarget) {
4352
+ const result = { url: managedTarget.page.url(), title: await managedTarget.page.title().catch(() => managedSession.title) };
4353
+ const handoff = startRuntimeManagedBrowserHandoff({
4354
+ repoRoot: input.repoRoot,
4355
+ repoId: input.repoId,
4356
+ requestId: input.requestId,
4357
+ jobId: input.jobId,
4358
+ sessionId: target.sessionId,
4359
+ sessionPath: sessionPath(input.repoRoot, target.sessionId),
4360
+ url: target.url,
4361
+ profileDir: managedTarget.profile.profileDir,
4362
+ selectedProfilePath: managedTarget.profile.selectedProfilePath,
4363
+ profileDirectory: managedTarget.profile.profileDirectory,
4364
+ browserChannel: current.browserChannel,
4365
+ executablePath: current.executablePath ? resolveConfiguredPath(input.repoRoot, current.executablePath) : undefined,
4366
+ defaultTimeoutMs: current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS,
4367
+ reason: stringValue(input.args.reason) ?? 'manual_review',
4368
+ instructions: stringValue(input.args.instructions),
4369
+ timeoutMs: typeof input.args.handoff_timeout_ms === 'number' ? input.args.handoff_timeout_ms : undefined,
4370
+ }, result);
4371
+ try {
4372
+ if (managedTarget.page.bringToFront) await managedTarget.page.bringToFront();
4373
+ } catch (error) {
4374
+ resolveRuntimeManagedBrowserHandoff(input.repoRoot, handoff.interactionId, 'cancel', result);
4375
+ throw error;
4376
+ }
4377
+ return {
4378
+ provider: 'playwright-runtime-managed-handoff',
4379
+ handoff,
4380
+ session: managedSession,
4381
+ nextAction: 'Complete the manual step, then call resolve_handoff with resolution=resume. Its response contains the updated handoff state; call get_handoff_status only when a later decision needs an observation, not as periodic polling.',
4382
+ };
4383
+ }
2318
4384
  const nativeSession = target.existingSession;
2319
4385
  const nativeTab = nativeSession?.browser?.tab;
2320
4386
  const nativeBrowser = nativeSession?.browser?.provider === 'macos-apple-events'
@@ -2356,14 +4422,18 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2356
4422
  provider: nativeBrowser ? 'macos-apple-events-handoff-host' : 'playwright-handoff-host',
2357
4423
  handoff,
2358
4424
  session: target.existingSession,
2359
- nextAction: 'Complete the manual step, call resolve_handoff with resolution=resume, then poll get_handoff_status.',
4425
+ nextAction: 'Complete the manual step, then call resolve_handoff with resolution=resume. Its response contains the updated handoff state; call get_handoff_status only when a later decision needs an observation, not as periodic polling.',
2360
4426
  };
2361
4427
  }
2362
4428
  case 'get_handoff_status': {
2363
4429
  const interactionId = requiredString(input.args.interaction_id, 'interaction_id');
2364
4430
  const handoff = getBrowserHandoff(input.repoRoot, interactionId);
2365
4431
  return {
2366
- provider: handoff.targetId.startsWith('macos-apple-events:') ? 'macos-apple-events-handoff-host' : 'playwright-handoff-host',
4432
+ provider: isRuntimeManagedBrowserHandoff(handoff)
4433
+ ? 'playwright-runtime-managed-handoff'
4434
+ : handoff.targetId.startsWith('macos-apple-events:')
4435
+ ? 'macos-apple-events-handoff-host'
4436
+ : 'playwright-handoff-host',
2367
4437
  handoff,
2368
4438
  };
2369
4439
  }
@@ -2373,6 +4443,28 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2373
4443
  if (resolution !== 'resume' && resolution !== 'cancel') {
2374
4444
  throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'resolution must be resume or cancel', { retryable: false });
2375
4445
  }
4446
+ const currentHandoff = getBrowserHandoff(input.repoRoot, interactionId);
4447
+ if (isRuntimeManagedBrowserHandoff(currentHandoff)) {
4448
+ const session = findSession(input.repoRoot, currentHandoff.sessionId);
4449
+ const managedTarget = session ? await runtimeManagedPageForSession(input.repoRoot, current, session) : undefined;
4450
+ const result = managedTarget
4451
+ ? { url: managedTarget.page.url(), title: await managedTarget.page.title().catch(() => session?.title) }
4452
+ : currentHandoff.result;
4453
+ if (resolution === 'resume' && session && result?.url) {
4454
+ saveSession(input.repoRoot, {
4455
+ ...session,
4456
+ url: result.url,
4457
+ title: result.title,
4458
+ updatedAt: new Date().toISOString(),
4459
+ });
4460
+ }
4461
+ const handoff = resolveRuntimeManagedBrowserHandoff(input.repoRoot, interactionId, resolution, result);
4462
+ return {
4463
+ provider: 'playwright-runtime-managed-handoff',
4464
+ resolutionRequested: resolution,
4465
+ handoff,
4466
+ };
4467
+ }
2376
4468
  const handoff = resolution === 'resume'
2377
4469
  ? resumeBrowserHandoff(input.repoRoot, interactionId, input.requestId)
2378
4470
  : cancelBrowserHandoff(input.repoRoot, interactionId, input.requestId);
@@ -2385,17 +4477,152 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2385
4477
  case 'create_session':
2386
4478
  case 'open_page':
2387
4479
  case 'navigate': {
2388
- const url = normalizedUrl(input.args.url);
4480
+ const nativeProduct = stringValue(input.args.native_browser_product);
4481
+ const normalizedNativeProduct: MacOsBrowserProduct | undefined = nativeProduct === 'chrome' || nativeProduct === 'vivaldi' ? nativeProduct : undefined;
4482
+ const nativeWindowId = stringValue(input.args.native_window_id);
4483
+ const nativeTabId = stringValue(input.args.native_tab_id);
4484
+ const nativeActiveTab = input.args.native_active_tab === true;
4485
+ const hasNativeAdoption = Boolean(nativeProduct || nativeWindowId || nativeTabId || nativeActiveTab);
4486
+ const url = hasNativeAdoption && input.actionId === 'create_session'
4487
+ ? normalizedExplicitNativeAdoptionUrl(input.args.url)
4488
+ : normalizedUrl(input.args.url);
2389
4489
  const existingSessionId = stringValue(input.args.session_id);
2390
4490
  const target: BrowserActionTarget = existingSessionId
2391
4491
  ? { sessionId: existingSessionId, url, existingSession: findSession(input.repoRoot, existingSessionId) }
2392
4492
  : { sessionId: sessionIdFor(url), url };
4493
+ if (hasNativeAdoption) {
4494
+ if (input.actionId !== 'create_session' || (nativeProduct && !normalizedNativeProduct)) {
4495
+ throw new AssistantPluginError(
4496
+ 'PLUGIN_ACTION_ARGUMENT_INVALID',
4497
+ 'Explicit native tab adoption is only supported by create_session for Chrome or Vivaldi.',
4498
+ { retryable: false },
4499
+ );
4500
+ }
4501
+ const timeout = positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS);
4502
+ let adoptedProduct: MacOsBrowserProduct;
4503
+ let adoptedRef: MacOsBrowserTabRef;
4504
+ let adoptedSystemFrontmost = false;
4505
+ if (nativeActiveTab) {
4506
+ if (nativeWindowId || nativeTabId) {
4507
+ throw new AssistantPluginError(
4508
+ 'PLUGIN_ACTION_ARGUMENT_INVALID',
4509
+ 'native_active_tab cannot be combined with native_window_id or native_tab_id.',
4510
+ { retryable: false },
4511
+ );
4512
+ }
4513
+ const configuredCandidates = (current.nativeBrowserCandidates ?? ['vivaldi', 'chrome'])
4514
+ .filter((candidate): candidate is MacOsBrowserProduct => candidate === 'chrome' || candidate === 'vivaldi');
4515
+ const candidates: MacOsBrowserProduct[] = normalizedNativeProduct ? [normalizedNativeProduct] : configuredCandidates;
4516
+ const discovered = await discoverMacOsBrowserAttachment(candidates, Math.min(timeout, MAX_CDP_DISCOVERY_TIMEOUT_MS));
4517
+ const activeMetadata = discovered.attachment?.metadata;
4518
+ const requiresSystemFrontmost = !normalizedNativeProduct;
4519
+ if (!activeMetadata || (requiresSystemFrontmost && (!activeMetadata.frontmost || activeMetadata.active !== true))) {
4520
+ throw new AssistantPluginError(
4521
+ 'PLUGIN_BROWSER_ACTIVE_TAB_UNAVAILABLE',
4522
+ requiresSystemFrontmost
4523
+ ? 'No system-frontmost active native browser tab with a stable native identity is available for adoption.'
4524
+ : 'The explicitly selected native browser does not expose an active tab with a stable native identity.',
4525
+ { retryable: true, details: { browserProduct: nativeProduct, requiresSystemFrontmost, attempts: discovered.attempts } },
4526
+ );
4527
+ }
4528
+ adoptedSystemFrontmost = activeMetadata.frontmost === true && activeMetadata.active === true;
4529
+ adoptedProduct = activeMetadata.product;
4530
+ if (activeMetadata.windowId && activeMetadata.tabId) {
4531
+ adoptedRef = { windowId: activeMetadata.windowId, tabId: activeMetadata.tabId };
4532
+ } else {
4533
+ const inventory = await listMacOsBrowserTabs(adoptedProduct, Math.min(timeout, MAX_CDP_DISCOVERY_TIMEOUT_MS));
4534
+ const matchingActiveTabs = inventory.tabs.filter((candidate) => candidate.active
4535
+ && comparableUrl(candidate.url) === comparableUrl(activeMetadata.url)
4536
+ && (!activeMetadata.windowId || candidate.windowId === activeMetadata.windowId)
4537
+ && (!activeMetadata.tabId || candidate.tabId === activeMetadata.tabId));
4538
+ if (matchingActiveTabs.length !== 1) {
4539
+ throw new AssistantPluginError(
4540
+ 'PLUGIN_BROWSER_ACTIVE_TAB_UNAVAILABLE',
4541
+ 'The explicitly selected native browser active tab metadata lacks a stable identity and tab inventory did not resolve exactly one matching active tab.',
4542
+ {
4543
+ retryable: true,
4544
+ details: {
4545
+ browserProduct: adoptedProduct,
4546
+ requiresSystemFrontmost,
4547
+ observedUrl: activeMetadata.url,
4548
+ observedTitle: activeMetadata.title,
4549
+ observedWindowId: activeMetadata.windowId,
4550
+ observedTabId: activeMetadata.tabId,
4551
+ matchingActiveTabCount: matchingActiveTabs.length,
4552
+ inventoryTruncated: inventory.truncated,
4553
+ attempts: discovered.attempts,
4554
+ },
4555
+ },
4556
+ );
4557
+ }
4558
+ adoptedRef = { windowId: matchingActiveTabs[0]!.windowId, tabId: matchingActiveTabs[0]!.tabId };
4559
+ }
4560
+ } else {
4561
+ if (!normalizedNativeProduct || !nativeWindowId || !nativeTabId) {
4562
+ throw new AssistantPluginError(
4563
+ 'PLUGIN_ACTION_ARGUMENT_INVALID',
4564
+ 'Explicit native tab adoption requires native_active_tab=true or native_browser_product with native_window_id and native_tab_id.',
4565
+ { retryable: false },
4566
+ );
4567
+ }
4568
+ adoptedProduct = normalizedNativeProduct;
4569
+ adoptedRef = { windowId: nativeWindowId, tabId: nativeTabId };
4570
+ }
4571
+ const adopted = await reattachMacOsBrowserOwnedPage(adoptedProduct, adoptedRef, timeout);
4572
+ const metadata = adopted.attachment.metadata;
4573
+ const pageUrl = normalizedExplicitNativeAdoptionUrl(metadata.url);
4574
+ const requestedUrl = normalizedExplicitNativeAdoptionUrl(url);
4575
+ const urlsMatch = explicitNativeAdoptionUrlsMatch(requestedUrl, pageUrl, adoptedProduct);
4576
+ const pageProtocol = new URL(pageUrl).protocol;
4577
+ if (((pageProtocol === 'chrome:' && adoptedProduct !== 'chrome') || (pageProtocol === 'vivaldi:' && adoptedProduct !== 'vivaldi')) && !urlsMatch) {
4578
+ throw new AssistantPluginError(
4579
+ 'PLUGIN_BROWSER_ADOPTED_TAB_URL_MISMATCH',
4580
+ 'Explicitly adopted native tab uses an internal URL scheme that does not match the selected browser.',
4581
+ { retryable: false, details: { actualUrl: pageUrl, browserProduct: adoptedProduct, windowId: adoptedRef.windowId, tabId: adoptedRef.tabId } },
4582
+ );
4583
+ }
4584
+ if (!urlsMatch) {
4585
+ throw new AssistantPluginError(
4586
+ 'PLUGIN_BROWSER_ADOPTED_TAB_URL_MISMATCH',
4587
+ 'Explicitly adopted native tab URL does not match the requested session URL.',
4588
+ { retryable: false, details: { requestedUrl, actualUrl: pageUrl, browserProduct: adoptedProduct, windowId: adoptedRef.windowId, tabId: adoptedRef.tabId } },
4589
+ );
4590
+ }
4591
+ const title = metadata.title || '';
4592
+ const connection = refreshConnectionTab({
4593
+ requestedMode: 'attach_preferred',
4594
+ mode: 'attach_preferred',
4595
+ provider: 'macos-apple-events',
4596
+ attached: true,
4597
+ browserProduct: adoptedProduct,
4598
+ profile: { selectedProfilePath: `macos:${adoptedProduct}:user-tab` },
4599
+ tabInventory: [{ index: 0, key: tabKey(requestedUrl, title), url: requestedUrl, title }],
4600
+ sessionResume: {
4601
+ sessionId: target.sessionId,
4602
+ status: 'matched',
4603
+ reason: nativeActiveTab && adoptedSystemFrontmost
4604
+ ? 'Explicitly adopted the matching system-frontmost active user-owned native browser tab.'
4605
+ : nativeActiveTab
4606
+ ? 'Explicitly adopted the selected browser active tab; system-frontmost authority was not established.'
4607
+ : 'Explicitly adopted an existing user-owned native browser tab.',
4608
+ },
4609
+ }, requestedUrl, title, 'saved_url', { ownership: 'user_owned', windowId: adoptedRef.windowId, tabId: adoptedRef.tabId });
4610
+ const session = saveSession(input.repoRoot, sessionFromPage(target, requestedUrl, title, connection));
4611
+ return {
4612
+ provider: 'macos-apple-events',
4613
+ session,
4614
+ navigation: { url: requestedUrl },
4615
+ browserConnection: { ...connection, tab: session.browser?.tab, sessionResume: session.browser?.sessionResume },
4616
+ ...(input.args.extract_text === true
4617
+ ? { text: await extractText(adopted.page as unknown as PageLike, undefined, positiveNumber(input.args.max_chars, DEFAULT_MAX_TEXT_CHARS)) }
4618
+ : {}),
4619
+ };
4620
+ }
2393
4621
  return await withPage(input.repoRoot, current, target, input.args, async (page, diagnostics, connection) => {
2394
- const identity = await readPageIdentity(page, connection);
2395
- const session = sessionFromPage(target, identity.url, identity.title, connection);
2396
- saveSession(input.repoRoot, session);
4622
+ const identity = await readPageIdentity(page, connection, target.url);
4623
+ const session = saveSession(input.repoRoot, sessionFromPage(target, identity.url, identity.title, connection));
2397
4624
  return {
2398
- provider: 'playwright',
4625
+ provider: browserResultProvider(connection.provider),
2399
4626
  session,
2400
4627
  navigation: diagnostics.navigation,
2401
4628
  browserConnection: {
@@ -2407,7 +4634,7 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2407
4634
  ? { text: await extractText(page, undefined, positiveNumber(input.args.max_chars, DEFAULT_MAX_TEXT_CHARS)) }
2408
4635
  : {}),
2409
4636
  };
2410
- });
4637
+ }, { requireExistingResource: Boolean(existingSessionId) });
2411
4638
  }
2412
4639
  case 'reload':
2413
4640
  case 'go_back':
@@ -2425,10 +4652,9 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2425
4652
  });
2426
4653
  }
2427
4654
  const identity = await readPageIdentity(page, connection);
2428
- const session = sessionFromPage(target, identity.url, identity.title, connection);
2429
- saveSession(input.repoRoot, session);
4655
+ const session = saveSession(input.repoRoot, sessionFromPage(target, identity.url, identity.title, connection));
2430
4656
  return {
2431
- provider: 'playwright',
4657
+ provider: browserResultProvider(connection.provider),
2432
4658
  session,
2433
4659
  navigation: diagnostics.navigation,
2434
4660
  actionId: input.actionId,
@@ -2438,28 +4664,34 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2438
4664
  sessionResume: session.browser?.sessionResume,
2439
4665
  },
2440
4666
  };
2441
- });
4667
+ }, input.actionId === 'reload'
4668
+ ? { requireExistingResource: true, pruneStaleSessionMetadata: true }
4669
+ : {});
2442
4670
  }
2443
4671
  case 'get_text': {
2444
4672
  const target = resolveActionTarget(input.repoRoot, input.args);
2445
- return {
2446
- provider: 'playwright',
2447
- sessionId: target.sessionId,
2448
- url: target.url,
2449
- ...(await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => ({
2450
- ...(await extractText(page, stringValue(input.args.selector), positiveNumber(input.args.max_chars, DEFAULT_MAX_TEXT_CHARS))),
4673
+ return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4674
+ const selection = resolveBrowserEvaluationScope(page, input.args, connection);
4675
+ return {
4676
+ provider: browserResultProvider(connection.provider),
4677
+ sessionId: target.sessionId,
4678
+ url: target.url,
4679
+ ...(await extractText(selection.scope, stringValue(input.args.selector), positiveNumber(input.args.max_chars, DEFAULT_MAX_TEXT_CHARS))),
4680
+ ...(selection.frame ? { frame: selection.frame } : {}),
2451
4681
  browserConnection: connection,
2452
- }), { persistSession: true })),
2453
- };
4682
+ };
4683
+ }, { persistSession: true });
2454
4684
  }
2455
4685
  case 'get_html': {
2456
4686
  const target = resolveActionTarget(input.repoRoot, input.args);
2457
4687
  return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
2458
- const raw = await page.evaluate<string>(EXTRACTION_SCRIPTS.html(stringValue(input.args.selector)));
4688
+ const selection = resolveBrowserEvaluationScope(page, input.args, connection);
4689
+ const raw = await selection.scope.evaluate<string>(EXTRACTION_SCRIPTS.html(stringValue(input.args.selector)));
2459
4690
  return {
2460
- provider: 'playwright',
4691
+ provider: browserResultProvider(connection.provider),
2461
4692
  sessionId: target.sessionId,
2462
4693
  url: target.url,
4694
+ ...(selection.frame ? { frame: selection.frame } : {}),
2463
4695
  browserConnection: connection,
2464
4696
  ...truncateText(raw, positiveNumber(input.args.max_chars, DEFAULT_MAX_TEXT_CHARS)),
2465
4697
  };
@@ -2471,12 +4703,14 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2471
4703
  const selector = requiredString(input.args.selector, 'selector');
2472
4704
  const limit = Math.min(positiveNumber(input.args.limit, 25), 100);
2473
4705
  return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
2474
- const matches = await page.evaluate<Array<Record<string, unknown>>>(EXTRACTION_SCRIPTS.query(selector, input.actionId === 'query_selector' ? 1 : limit));
4706
+ const selection = resolveBrowserEvaluationScope(page, input.args, connection);
4707
+ const matches = await selection.scope.evaluate<Array<Record<string, unknown>>>(EXTRACTION_SCRIPTS.query(selector, input.actionId === 'query_selector' ? 1 : limit));
2475
4708
  return {
2476
- provider: 'playwright',
4709
+ provider: browserResultProvider(connection.provider),
2477
4710
  sessionId: target.sessionId,
2478
4711
  url: target.url,
2479
4712
  selector,
4713
+ ...(selection.frame ? { frame: selection.frame } : {}),
2480
4714
  browserConnection: connection,
2481
4715
  ...(input.actionId === 'query_selector'
2482
4716
  ? { match: matches[0] ?? null, found: matches.length > 0 }
@@ -2488,15 +4722,95 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2488
4722
  const target = resolveActionTarget(input.repoRoot, input.args);
2489
4723
  const selector = requiredString(input.args.selector, 'selector');
2490
4724
  const attribute = requiredString(input.args.attribute, 'attribute');
2491
- return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => ({
2492
- provider: 'playwright',
2493
- sessionId: target.sessionId,
2494
- url: target.url,
2495
- selector,
2496
- attribute,
2497
- browserConnection: connection,
2498
- value: await page.evaluate<string | null>(EXTRACTION_SCRIPTS.attribute(selector, attribute)),
2499
- }), { persistSession: true });
4725
+ return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4726
+ const selection = resolveBrowserEvaluationScope(page, input.args, connection);
4727
+ return {
4728
+ provider: browserResultProvider(connection.provider),
4729
+ sessionId: target.sessionId,
4730
+ url: target.url,
4731
+ selector,
4732
+ attribute,
4733
+ ...(selection.frame ? { frame: selection.frame } : {}),
4734
+ browserConnection: connection,
4735
+ value: await selection.scope.evaluate<string | null>(EXTRACTION_SCRIPTS.attribute(selector, attribute)),
4736
+ };
4737
+ }, { persistSession: true });
4738
+ }
4739
+ case 'list_frames': {
4740
+ const target = resolveActionTarget(input.repoRoot, input.args);
4741
+ const limit = Math.min(positiveNumber(input.args.limit, 50), 100);
4742
+ return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4743
+ if (connection.provider === 'macos-apple-events' || !page.frames) {
4744
+ throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_SCOPE_UNAVAILABLE', 'Frame discovery requires a Playwright/CDP-controlled page; native Apple Events sessions fail closed.', {
4745
+ retryable: false,
4746
+ details: { provider: connection.provider },
4747
+ });
4748
+ }
4749
+ const allFrames = page.frames();
4750
+ const frames = allFrames.slice(0, limit).map((frame) => ({ url: frame.url(), name: frame.name() }));
4751
+ return {
4752
+ provider: browserResultProvider(connection.provider),
4753
+ sessionId: target.sessionId,
4754
+ url: target.url,
4755
+ frames,
4756
+ count: allFrames.length,
4757
+ truncated: allFrames.length > frames.length,
4758
+ browserConnection: connection,
4759
+ };
4760
+ }, { persistSession: true });
4761
+ }
4762
+ case 'verify_state': {
4763
+ requiredString(input.args.session_id, 'session_id');
4764
+ const target = resolveActionTarget(input.repoRoot, input.args);
4765
+ const expectedUrlRaw = stringValue(input.args.expected_url);
4766
+ const expectedUrl = expectedUrlRaw ? normalizedUrl(expectedUrlRaw) : undefined;
4767
+ const urlContains = stringValue(input.args.url_contains);
4768
+ const selector = stringValue(input.args.selector);
4769
+ const requireVisible = input.args.require_visible === true;
4770
+ const textContains = stringValue(input.args.text_contains);
4771
+ if (!expectedUrl && !urlContains && !selector && !textContains) {
4772
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'verify_state requires at least one of expected_url, url_contains, selector, or text_contains.', { retryable: false });
4773
+ }
4774
+ if (requireVisible && !selector) {
4775
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'require_visible requires selector.', { retryable: false });
4776
+ }
4777
+ return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4778
+ const selection = resolveBrowserEvaluationScope(page, input.args, connection);
4779
+ const observedUrl = selection.frame?.url ?? page.url();
4780
+ const checks: Array<Record<string, unknown>> = [];
4781
+ if (expectedUrl) checks.push({ criterion: 'url_exact', expected: expectedUrl, observed: observedUrl, matched: observedUrl === expectedUrl });
4782
+ if (urlContains) checks.push({ criterion: 'url_contains', expected: urlContains, observed: observedUrl, matched: observedUrl.includes(urlContains) });
4783
+ let observation: { verificationVersion?: unknown; selectorExists?: unknown; visible?: unknown; textContains?: unknown; textSample?: unknown; textLength?: unknown; truncated?: unknown } | undefined;
4784
+ if (selector || textContains) {
4785
+ observation = await selection.scope.evaluate(verifyStateObservationScript(selector, textContains, Math.min(positiveNumber(input.args.max_chars, 2_000), 100_000)));
4786
+ if (observation?.verificationVersion !== 1) {
4787
+ throw new AssistantPluginError('PLUGIN_BROWSER_VERIFY_STATE_UNAVAILABLE', 'Browser state observation returned an unsupported result.', { retryable: true });
4788
+ }
4789
+ }
4790
+ if (selector) {
4791
+ checks.push({ criterion: 'selector_exists', expected: true, observed: observation?.selectorExists === true, matched: observation?.selectorExists === true, selector });
4792
+ if (requireVisible) {
4793
+ checks.push({ criterion: 'selector_visible', expected: true, observed: observation?.visible === true, matched: observation?.visible === true, selector });
4794
+ }
4795
+ }
4796
+ if (textContains) {
4797
+ checks.push({
4798
+ criterion: 'text_contains', expected: textContains, observed: observation?.textSample,
4799
+ matched: observation?.textContains === true, textLength: observation?.textLength, truncated: observation?.truncated,
4800
+ ...(selector ? { selector } : {}),
4801
+ });
4802
+ }
4803
+ return {
4804
+ provider: browserResultProvider(connection.provider),
4805
+ sessionId: target.sessionId,
4806
+ url: target.url,
4807
+ observedUrl,
4808
+ ...(selection.frame ? { frame: selection.frame } : {}),
4809
+ matched: checks.every((check) => check.matched === true),
4810
+ checks,
4811
+ browserConnection: connection,
4812
+ };
4813
+ }, { requireExistingResource: true });
2500
4814
  }
2501
4815
  case 'screenshot': {
2502
4816
  const target = resolveActionTarget(input.repoRoot, input.args);
@@ -2520,7 +4834,7 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2520
4834
  browserConnection: connection,
2521
4835
  };
2522
4836
  });
2523
- return { provider: 'playwright', screenshot };
4837
+ return { provider: browserResultProvider(screenshot.browserConnection.provider), screenshot };
2524
4838
  }
2525
4839
  case 'extract_links':
2526
4840
  case 'extract_tables':
@@ -2536,13 +4850,39 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2536
4850
  : input.actionId === 'extract_forms'
2537
4851
  ? EXTRACTION_SCRIPTS.forms(limit)
2538
4852
  : EXTRACTION_SCRIPTS.interactive(limit);
2539
- const data = await page.evaluate<unknown>(script);
4853
+ const selection = input.actionId === 'snapshot_interactive'
4854
+ ? resolveBrowserEvaluationScope(page, input.args, connection)
4855
+ : { scope: page } as BrowserFrameSelection;
4856
+ const evaluated = await selection.scope.evaluate<unknown>(script);
4857
+ const grounded = input.actionId === 'snapshot_interactive' && evaluated && typeof evaluated === 'object' && !Array.isArray(evaluated)
4858
+ ? evaluated as { geometryVersion?: unknown; viewport?: unknown; elements?: unknown }
4859
+ : undefined;
4860
+ let data = grounded && Array.isArray(grounded.elements) ? grounded.elements : evaluated;
4861
+ let viewport = grounded?.viewport;
4862
+ let frameViewport: unknown;
4863
+ let frameOffset: { x: number; y: number; width: number; height: number } | undefined;
4864
+ if (grounded?.geometryVersion === 1 && selection.frame && Array.isArray(grounded.elements)) {
4865
+ frameOffset = await resolveFrameViewportOffset(page, selection);
4866
+ if (!frameOffset) {
4867
+ throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_GEOMETRY_UNAVAILABLE', 'Frame geometry offset was not available.', { retryable: true });
4868
+ }
4869
+ const topMetrics = await page.evaluate<{ viewportMetricsVersion?: unknown; viewport?: unknown }>(VIEWPORT_METRICS_SCRIPT);
4870
+ if (topMetrics?.viewportMetricsVersion !== 1 || !topMetrics.viewport) {
4871
+ throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_GEOMETRY_UNAVAILABLE', 'Top-level viewport metrics were not available for frame grounding.', { retryable: true });
4872
+ }
4873
+ frameViewport = grounded.viewport;
4874
+ viewport = topMetrics.viewport;
4875
+ data = translateFrameGroundingElements(grounded.elements, frameOffset, topMetrics.viewport);
4876
+ }
2540
4877
  return {
2541
- provider: 'playwright',
4878
+ provider: browserResultProvider(connection.provider),
2542
4879
  sessionId: target.sessionId,
2543
4880
  url: target.url,
2544
4881
  actionId: input.actionId,
2545
4882
  data,
4883
+ ...(selection.frame ? { frame: selection.frame } : {}),
4884
+ ...(grounded?.geometryVersion === 1 ? { geometryVersion: 1, viewport } : {}),
4885
+ ...(selection.frame && frameOffset ? { coordinateSpace: 'main_viewport_css', frameViewport, frameOffset } : {}),
2546
4886
  browserConnection: connection,
2547
4887
  };
2548
4888
  }, { persistSession: true });
@@ -2550,16 +4890,83 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2550
4890
  case 'get_console_errors':
2551
4891
  case 'get_failed_requests': {
2552
4892
  const target = resolveActionTarget(input.repoRoot, input.args);
2553
- return await withPage(input.repoRoot, current, target, input.args, async (_page, diagnostics, connection) => ({
2554
- provider: 'playwright',
2555
- sessionId: target.sessionId,
2556
- url: target.url,
2557
- navigation: diagnostics.navigation,
2558
- browserConnection: connection,
2559
- ...(input.actionId === 'get_console_errors'
2560
- ? { consoleErrors: diagnostics.consoleErrors.slice(0, 50) }
2561
- : { failedRequests: diagnostics.failedRequests.slice(0, 50) }),
2562
- }), { persistSession: true });
4893
+ return await withPage(input.repoRoot, current, target, input.args, async (_page, diagnostics, connection) => {
4894
+ if (connection.provider === 'macos-apple-events') {
4895
+ throw new AssistantPluginError(
4896
+ 'PLUGIN_BROWSER_DIAGNOSTICS_UNAVAILABLE',
4897
+ 'Console and failed-request diagnostics require a Playwright/CDP-controlled page; native Apple Events sessions do not expose browser diagnostic events.',
4898
+ { retryable: false, details: { provider: connection.provider, actionId: input.actionId, sessionId: target.sessionId } },
4899
+ );
4900
+ }
4901
+ return {
4902
+ provider: browserResultProvider(connection.provider),
4903
+ sessionId: target.sessionId,
4904
+ url: target.url,
4905
+ navigation: diagnostics.navigation,
4906
+ browserConnection: connection,
4907
+ ...(input.actionId === 'get_console_errors'
4908
+ ? { consoleErrors: diagnostics.consoleErrors.slice(0, 50) }
4909
+ : { failedRequests: diagnostics.failedRequests.slice(0, 50) }),
4910
+ };
4911
+ }, { persistSession: true });
4912
+ }
4913
+ case 'activate_page': {
4914
+ const target = resolveActionTarget(input.repoRoot, input.args);
4915
+ return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4916
+ const waitMs = positiveNumber(input.args.post_action_wait_ms, DEFAULT_POST_ACTION_WAIT_MS);
4917
+ if (connection.provider === 'macos-apple-events') {
4918
+ await establishAuthoritativeNativeForeground(input, page, connection, target, waitMs);
4919
+ } else {
4920
+ if (!page.bringToFront) {
4921
+ throw new AssistantPluginError('PLUGIN_BROWSER_ACTIVATION_UNSUPPORTED', 'The selected browser provider cannot bring the saved page to the foreground.', { retryable: false });
4922
+ }
4923
+ await page.bringToFront();
4924
+ await delay(waitMs);
4925
+ }
4926
+ return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'activate_page', 'Activated the exact saved browser page in authoritative foreground state.', {});
4927
+ });
4928
+ }
4929
+ case 'click_text': {
4930
+ const target = resolveActionTarget(input.repoRoot, input.args);
4931
+ const text = requiredString(input.args.text, 'text');
4932
+ if (text.length > 200) {
4933
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'text must be 200 characters or fewer.', { retryable: false });
4934
+ }
4935
+ return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4936
+ const clicked = await page.evaluate((payload) => {
4937
+ const args = payload as { text: string };
4938
+ const normalize = (value: string) => value.replace(/\s+/g, ' ').trim();
4939
+ const roots: Array<Document | ShadowRoot> = [document];
4940
+ const candidates: HTMLElement[] = [];
4941
+ for (let index = 0; index < roots.length; index += 1) {
4942
+ const root = roots[index];
4943
+ candidates.push(...Array.from(root.querySelectorAll<HTMLElement>('button,[role=button],a,div,span')));
4944
+ for (const host of Array.from(root.querySelectorAll<HTMLElement>('*'))) {
4945
+ if (host.shadowRoot) roots.push(host.shadowRoot);
4946
+ }
4947
+ }
4948
+ const matches = candidates
4949
+ .filter((element) => normalize(element.innerText || element.textContent || '') === args.text)
4950
+ .filter((element) => {
4951
+ const style = getComputedStyle(element);
4952
+ const rect = element.getBoundingClientRect();
4953
+ return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
4954
+ })
4955
+ .sort((left, right) => {
4956
+ const childDelta = left.children.length - right.children.length;
4957
+ if (childDelta !== 0) return childDelta;
4958
+ const leftRect = left.getBoundingClientRect();
4959
+ const rightRect = right.getBoundingClientRect();
4960
+ return (leftRect.width * leftRect.height) - (rightRect.width * rightRect.height);
4961
+ });
4962
+ const element = matches[0];
4963
+ if (!element) throw new Error(`visible exact text not found: ${args.text}`);
4964
+ element.click();
4965
+ return { tag: element.tagName.toLowerCase(), className: element.className || '', text: normalize(element.innerText || element.textContent || '') };
4966
+ }, { text });
4967
+ await delay(positiveNumber(input.args.post_action_wait_ms, DEFAULT_POST_ACTION_WAIT_MS));
4968
+ return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'click_text', `Clicked exact visible text ${text}.`, { text, clicked });
4969
+ });
2563
4970
  }
2564
4971
  case 'click':
2565
4972
  case 'double_click':
@@ -2572,7 +4979,22 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2572
4979
  const timeoutMs = positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS);
2573
4980
  try {
2574
4981
  return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
2575
- if (input.actionId === 'click') await page.click(selector, { timeout: timeoutMs });
4982
+ if (input.actionId === 'click' && openShadowSelectorParts(selector).length > 1) {
4983
+ const parts = openShadowSelectorParts(selector);
4984
+ await page.evaluate((payload) => {
4985
+ const args = payload as { parts: string[] };
4986
+ let root: Document | ShadowRoot = document;
4987
+ for (let index = 0; index < args.parts.length - 1; index += 1) {
4988
+ const host: Element | null = root.querySelector(args.parts[index]);
4989
+ if (!host || !host.shadowRoot) throw new Error(`Open shadow root not found for selector segment: ${args.parts[index]}`);
4990
+ root = host.shadowRoot;
4991
+ }
4992
+ const element = root.querySelector(args.parts[args.parts.length - 1]) as HTMLElement | null;
4993
+ if (!element || typeof element.click !== 'function') throw new Error('Shadow selector did not resolve to a clickable element.');
4994
+ element.click();
4995
+ return true;
4996
+ }, { parts });
4997
+ } else if (input.actionId === 'click') await page.click(selector, { timeout: timeoutMs });
2576
4998
  else if (input.actionId === 'double_click') {
2577
4999
  if (page.dblclick) await page.dblclick(selector, { timeout: timeoutMs });
2578
5000
  else await page.click(selector, { timeout: timeoutMs, clickCount: 2 } as Record<string, unknown>);
@@ -2631,9 +5053,15 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2631
5053
  const values = Array.isArray(input.args.values) ? input.args.values.map(String) : [];
2632
5054
  if (values.length === 0) throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'values is required.', { retryable: false });
2633
5055
  return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
2634
- if (page.selectOption) await page.selectOption(selector, values, { timeout: positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS) });
5056
+ if (!page.selectOption) {
5057
+ throw new AssistantPluginError('PLUGIN_BROWSER_SELECT_OPTION_UNAVAILABLE', 'The active browser provider cannot select options safely.', { retryable: false });
5058
+ }
5059
+ const selectedValues = await page.selectOption(selector, values, { timeout: positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS) });
5060
+ if (!Array.isArray(selectedValues) || selectedValues.length === 0) {
5061
+ throw new AssistantPluginError('PLUGIN_BROWSER_SELECT_OPTION_FAILED', 'The browser provider did not retain any requested option selection.', { retryable: true, details: { selector, requestedCount: values.length } });
5062
+ }
2635
5063
  await delay(positiveNumber(input.args.post_action_wait_ms, DEFAULT_POST_ACTION_WAIT_MS));
2636
- return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'select_option', `Selected ${values.length} option(s) on ${selector}.`, { selector, values });
5064
+ return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'select_option', `Selected ${values.length} option(s) on ${selector}.`, { selector, values, selectedValues });
2637
5065
  });
2638
5066
  }
2639
5067
  case 'press': {
@@ -2659,6 +5087,172 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2659
5087
  return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'keyboard_shortcut', `Pressed shortcut ${key}.`, { key });
2660
5088
  });
2661
5089
  }
5090
+ case 'trusted_input': {
5091
+ const target = resolveActionTarget(input.repoRoot, input.args);
5092
+ const kind = requiredString(input.args.kind, 'kind');
5093
+ if (!['click', 'move', 'wheel', 'drag', 'key', 'text'].includes(kind)) {
5094
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'kind must be click, move, wheel, drag, key, or text.', { retryable: false });
5095
+ }
5096
+ const guardSelector = stringValue(input.args.guard_selector);
5097
+ const hasFrameScope = Boolean(stringValue(input.args.frame_url) || stringValue(input.args.frame_name));
5098
+ if (hasFrameScope && !guardSelector) {
5099
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'trusted_input frame scope requires guard_selector; trusted input coordinates are always main-viewport CSS pixels.', { retryable: false });
5100
+ }
5101
+ if (guardSelector && kind !== 'click' && kind !== 'drag') {
5102
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'guard_selector is supported only for trusted_input click or drag.', { retryable: false });
5103
+ }
5104
+ return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
5105
+ const nativeTrustedInput = connection.provider === 'macos-apple-events';
5106
+ if (nativeTrustedInput && !page.trustedInput) {
5107
+ throw new AssistantPluginError(
5108
+ 'PLUGIN_BROWSER_TRUSTED_INPUT_UNAVAILABLE',
5109
+ 'Native browser provider does not expose the stable macOS trusted-input broker.',
5110
+ { retryable: false, details: { provider: connection.provider, sessionId: target.sessionId } },
5111
+ );
5112
+ }
5113
+ if (!nativeTrustedInput && (!page.mouse || !page.keyboard)) {
5114
+ throw new AssistantPluginError(
5115
+ 'PLUGIN_BROWSER_TRUSTED_INPUT_UNAVAILABLE',
5116
+ 'Trusted browser input requires Playwright mouse/keyboard primitives or the stable native macOS trusted-input broker.',
5117
+ { retryable: false, details: { provider: connection.provider, sessionId: target.sessionId } },
5118
+ );
5119
+ }
5120
+ const button = input.args.button === 'middle' || input.args.button === 'right' ? input.args.button : 'left';
5121
+ let guard: Record<string, unknown> | undefined;
5122
+ if (guardSelector) {
5123
+ const point = kind === 'click'
5124
+ ? { x: requiredFiniteNumber(input.args.x, 'x', 0), y: requiredFiniteNumber(input.args.y, 'y', 0) }
5125
+ : { x: requiredFiniteNumber(input.args.from_x, 'from_x', 0), y: requiredFiniteNumber(input.args.from_y, 'from_y', 0) };
5126
+ const selection = resolveBrowserEvaluationScope(page, input.args, connection);
5127
+ const observed = await selection.scope.evaluate<{
5128
+ trustedInputGuardVersion?: unknown; found?: unknown; visible?: unknown; selectorError?: unknown;
5129
+ bounds?: { x?: unknown; y?: unknown; width?: unknown; height?: unknown; right?: unknown; bottom?: unknown };
5130
+ }>(trustedInputGuardScript(guardSelector));
5131
+ if (observed?.trustedInputGuardVersion !== 1 || observed.found !== true || observed.visible !== true || !observed.bounds) {
5132
+ throw new AssistantPluginError('PLUGIN_BROWSER_TRUSTED_INPUT_GUARD_MISMATCH', 'Trusted input guard selector is missing or not visibly actionable.', {
5133
+ retryable: true,
5134
+ details: { selector: guardSelector, frame: selection.frame, found: observed?.found, visible: observed?.visible, selectorError: observed?.selectorError },
5135
+ });
5136
+ }
5137
+ const finite = (value: unknown, field: string): number => {
5138
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
5139
+ throw new AssistantPluginError('PLUGIN_BROWSER_TRUSTED_INPUT_GUARD_MISMATCH', `Trusted input guard ${field} is not finite.`, { retryable: true });
5140
+ }
5141
+ return value;
5142
+ };
5143
+ let bounds = {
5144
+ x: finite(observed.bounds.x, 'bounds.x'), y: finite(observed.bounds.y, 'bounds.y'),
5145
+ width: finite(observed.bounds.width, 'bounds.width'), height: finite(observed.bounds.height, 'bounds.height'),
5146
+ right: finite(observed.bounds.right, 'bounds.right'), bottom: finite(observed.bounds.bottom, 'bounds.bottom'),
5147
+ };
5148
+ if (selection.frame) {
5149
+ const offset = await resolveFrameViewportOffset(page, selection);
5150
+ if (!offset) throw new AssistantPluginError('PLUGIN_BROWSER_TRUSTED_INPUT_GUARD_MISMATCH', 'Trusted input frame offset is unavailable.', { retryable: true });
5151
+ bounds = {
5152
+ ...bounds,
5153
+ x: bounds.x + offset.x,
5154
+ y: bounds.y + offset.y,
5155
+ right: bounds.right + offset.x,
5156
+ bottom: bounds.bottom + offset.y,
5157
+ };
5158
+ }
5159
+ const metrics = await page.evaluate<{ viewportMetricsVersion?: unknown; viewport?: { width?: unknown; height?: unknown } }>(VIEWPORT_METRICS_SCRIPT);
5160
+ const viewportWidth = metrics?.viewportMetricsVersion === 1 && typeof metrics.viewport?.width === 'number' && Number.isFinite(metrics.viewport.width) ? metrics.viewport.width : undefined;
5161
+ const viewportHeight = metrics?.viewportMetricsVersion === 1 && typeof metrics.viewport?.height === 'number' && Number.isFinite(metrics.viewport.height) ? metrics.viewport.height : undefined;
5162
+ if (viewportWidth === undefined || viewportHeight === undefined) {
5163
+ throw new AssistantPluginError('PLUGIN_BROWSER_TRUSTED_INPUT_GUARD_MISMATCH', 'Trusted input guard could not verify the top-level viewport.', { retryable: true });
5164
+ }
5165
+ const targetInViewport = bounds.right > 0 && bounds.bottom > 0 && bounds.x < viewportWidth && bounds.y < viewportHeight;
5166
+ const pointInViewport = point.x >= 0 && point.y >= 0 && point.x < viewportWidth && point.y < viewportHeight;
5167
+ const pointInsideTarget = point.x >= bounds.x && point.x <= bounds.right && point.y >= bounds.y && point.y <= bounds.bottom;
5168
+ if (!targetInViewport || !pointInViewport || !pointInsideTarget) {
5169
+ throw new AssistantPluginError('PLUGIN_BROWSER_TRUSTED_INPUT_GUARD_MISMATCH', 'Trusted input coordinates no longer match the guarded selector geometry.', {
5170
+ retryable: true,
5171
+ details: { selector: guardSelector, frame: selection.frame, point, bounds, targetInViewport, pointInViewport, pointInsideTarget },
5172
+ });
5173
+ }
5174
+ guard = { selector: guardSelector, ...(selection.frame ? { frame: selection.frame } : {}), point, bounds, targetInViewport: true, pointInViewport: true, pointInsideTarget: true };
5175
+ }
5176
+ let trustedRequest: Record<string, unknown>;
5177
+ if (kind === 'click') {
5178
+ const x = requiredFiniteNumber(input.args.x, 'x', 0);
5179
+ const y = requiredFiniteNumber(input.args.y, 'y', 0);
5180
+ const clickCount = Math.min(Math.max(positiveNumber(input.args.click_count, 1), 1), 3);
5181
+ trustedRequest = { kind, x, y, button, clickCount };
5182
+ if (!nativeTrustedInput) await page.mouse!.click(x, y, { button, clickCount });
5183
+ } else if (kind === 'move') {
5184
+ const x = requiredFiniteNumber(input.args.x, 'x', 0);
5185
+ const y = requiredFiniteNumber(input.args.y, 'y', 0);
5186
+ const steps = Math.min(Math.max(positiveNumber(input.args.steps, 1), 1), 100);
5187
+ trustedRequest = { kind, x, y, steps };
5188
+ if (!nativeTrustedInput) await page.mouse!.move(x, y, { steps });
5189
+ } else if (kind === 'wheel') {
5190
+ const deltaX = requiredFiniteNumber(input.args.delta_x, 'delta_x');
5191
+ const deltaY = requiredFiniteNumber(input.args.delta_y, 'delta_y');
5192
+ trustedRequest = { kind, deltaX, deltaY };
5193
+ if (!nativeTrustedInput) await page.mouse!.wheel(deltaX, deltaY);
5194
+ } else if (kind === 'drag') {
5195
+ const fromX = requiredFiniteNumber(input.args.from_x, 'from_x', 0);
5196
+ const fromY = requiredFiniteNumber(input.args.from_y, 'from_y', 0);
5197
+ const toX = requiredFiniteNumber(input.args.to_x, 'to_x', 0);
5198
+ const toY = requiredFiniteNumber(input.args.to_y, 'to_y', 0);
5199
+ const steps = Math.min(Math.max(positiveNumber(input.args.steps, 10), 1), 100);
5200
+ trustedRequest = { kind, fromX, fromY, toX, toY, button, steps };
5201
+ if (!nativeTrustedInput) {
5202
+ await page.mouse!.move(fromX, fromY);
5203
+ await page.mouse!.down({ button });
5204
+ try {
5205
+ await page.mouse!.move(toX, toY, { steps });
5206
+ } finally {
5207
+ await page.mouse!.up({ button });
5208
+ }
5209
+ }
5210
+ } else if (kind === 'key') {
5211
+ const key = requiredString(input.args.key, 'key');
5212
+ trustedRequest = { kind, key };
5213
+ if (!nativeTrustedInput) await page.keyboard!.press(key);
5214
+ } else {
5215
+ const text = typeof input.args.text === 'string' ? input.args.text : undefined;
5216
+ if (text === undefined || text.length > 10_000) {
5217
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'text is required and must be at most 10000 characters.', { retryable: false });
5218
+ }
5219
+ trustedRequest = { kind, text };
5220
+ if (!nativeTrustedInput) {
5221
+ if (!page.keyboard!.insertText) {
5222
+ throw new AssistantPluginError('PLUGIN_BROWSER_TRUSTED_INPUT_UNAVAILABLE', 'This Playwright page does not expose trusted text insertion.', { retryable: false });
5223
+ }
5224
+ await page.keyboard!.insertText(text);
5225
+ }
5226
+ }
5227
+ if (nativeTrustedInput) await page.trustedInput!(trustedRequest);
5228
+ await delay(positiveNumber(input.args.post_action_wait_ms, DEFAULT_POST_ACTION_WAIT_MS));
5229
+ return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'trusted_input', `Sent trusted browser input (${kind}).`, { kind, ...(guard ? { guard } : {}) });
5230
+ });
5231
+ }
5232
+ case 'dispatch_event': {
5233
+ const target = resolveActionTarget(input.repoRoot, input.args);
5234
+ const selector = requiredString(input.args.selector, 'selector');
5235
+ const event = requiredString(input.args.event, 'event');
5236
+ if (!/^[A-Za-z][A-Za-z0-9:_-]{0,63}$/.test(event)) {
5237
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'event must be a simple DOM event name (letters, digits, colon, underscore, or hyphen; max 64 chars).', { retryable: false });
5238
+ }
5239
+ return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
5240
+ const dispatched = await page.evaluate((payload) => {
5241
+ const args = payload as { selector: string; event: string };
5242
+ const element = document.querySelector(args.selector);
5243
+ if (!element) throw new Error(`element not found: ${args.selector}`);
5244
+ const domEvent = new CustomEvent(args.event, { bubbles: true, composed: true, cancelable: true });
5245
+ const accepted = element.dispatchEvent(domEvent);
5246
+ return { accepted, defaultPrevented: domEvent.defaultPrevented };
5247
+ }, { selector, event });
5248
+ await delay(positiveNumber(input.args.post_action_wait_ms, DEFAULT_POST_ACTION_WAIT_MS));
5249
+ return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'dispatch_event', `Dispatched ${event} on ${selector}.`, {
5250
+ selector,
5251
+ event,
5252
+ dispatched,
5253
+ });
5254
+ });
5255
+ }
2662
5256
  case 'wait_for_selector': {
2663
5257
  const target = resolveActionTarget(input.repoRoot, input.args);
2664
5258
  const selector = requiredString(input.args.selector, 'selector');
@@ -2677,64 +5271,122 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2677
5271
  case 'attach_local_file': {
2678
5272
  const target = resolveActionTarget(input.repoRoot, input.args);
2679
5273
  const selector = requiredString(input.args.selector, 'selector');
2680
- const filePath = requiredString(input.args.file_path, 'file_path');
2681
- const resolved = resolve(input.repoRoot, filePath);
2682
- if (!existsSync(resolved)) {
2683
- throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'file_path does not exist.', { retryable: false });
5274
+ const single = stringValue(input.args.file_path);
5275
+ const many = stringList(input.args.file_paths);
5276
+ if (single && many) {
5277
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'Use either file_path or file_paths, not both.', { retryable: false });
2684
5278
  }
2685
- if (/\.(exe|dmg|pkg|sh|bat|cmd|app)$/i.test(resolved)) {
2686
- throw new AssistantPluginError('PLUGIN_POLICY_BLOCKED', 'Executable file attachments are not allowed.', { retryable: false });
5279
+ const requested = many ?? (single ? [single] : []);
5280
+ if (requested.length === 0 || requested.length > 32) {
5281
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'attach_local_file requires file_path or 1-32 file_paths.', { retryable: false });
2687
5282
  }
2688
- return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
2689
- if (connection.provider === 'macos-apple-events') {
2690
- throw new AssistantPluginError('PLUGIN_BROWSER_NATIVE_ACTION_UNSUPPORTED', 'Native Apple Events attachment does not support local file input. Use CDP or a managed browser context.', { retryable: false });
5283
+ const repositoryLexicalRoot = resolve(input.repoRoot);
5284
+ const repositoryRoot = realpathSync(input.repoRoot);
5285
+ const resolved = requested.map((filePath) => {
5286
+ const lexicalPath = resolve(input.repoRoot, filePath);
5287
+ const lexicalRelative = relative(repositoryLexicalRoot, lexicalPath);
5288
+ if (lexicalRelative === '..' || lexicalRelative.startsWith(`..${sep}`) || isAbsolute(lexicalRelative)) {
5289
+ throw new AssistantPluginError('PLUGIN_POLICY_BLOCKED', 'Local file attachments must stay inside the repository root.', { retryable: false, details: { fileName: basename(filePath) } });
5290
+ }
5291
+ if (!existsSync(lexicalPath)) {
5292
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', `Local attachment does not exist: ${basename(lexicalPath)}`, { retryable: false });
5293
+ }
5294
+ const realPath = realpathSync(lexicalPath);
5295
+ const repositoryRelative = relative(repositoryRoot, realPath);
5296
+ if (repositoryRelative === '..' || repositoryRelative.startsWith(`..${sep}`) || isAbsolute(repositoryRelative)) {
5297
+ throw new AssistantPluginError('PLUGIN_POLICY_BLOCKED', 'Local file attachments must resolve inside the repository root.', { retryable: false, details: { fileName: basename(lexicalPath) } });
2691
5298
  }
5299
+ if (!statSync(realPath).isFile()) {
5300
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', `Local attachment is not a regular file: ${basename(lexicalPath)}`, { retryable: false });
5301
+ }
5302
+ if (/\.(exe|dmg|pkg|sh|bat|cmd|app)$/i.test(realPath)) {
5303
+ throw new AssistantPluginError('PLUGIN_POLICY_BLOCKED', 'Executable file attachments are not allowed.', { retryable: false });
5304
+ }
5305
+ return lexicalPath;
5306
+ });
5307
+ return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
2692
5308
  await page.evaluate((payload) => {
2693
- const args = payload as { selector: string; path: string };
5309
+ const args = payload as { selector: string; count: number };
2694
5310
  const el = document.querySelector(args.selector) as HTMLInputElement | null;
2695
- if (!el) throw new Error(`input not found: ${args.selector}`);
2696
- }, { selector, path: resolved });
2697
- // Prefer Playwright setInputFiles when available via evaluate fallback marker
2698
- const anyPage = page as PageLike & { setInputFiles?: (selector: string, files: string | string[]) => Promise<void> };
2699
- if (typeof anyPage.setInputFiles === 'function') {
2700
- await anyPage.setInputFiles(selector, resolved);
5311
+ if (!(el instanceof HTMLInputElement) || el.type !== 'file') throw new Error(`file input not found: ${args.selector}`);
5312
+ if (args.count > 1 && !el.multiple) throw new Error(`file input does not allow multiple files: ${args.selector}`);
5313
+ }, { selector, count: resolved.length });
5314
+ if (typeof page.setInputFiles !== 'function') {
5315
+ throw new AssistantPluginError('PLUGIN_BROWSER_FILE_ATTACH_UNSUPPORTED', 'The selected browser provider does not support local file attachment.', { retryable: false });
2701
5316
  }
5317
+ await page.setInputFiles(selector, resolved.length === 1 ? resolved[0] : resolved);
2702
5318
  await delay(positiveNumber(input.args.post_action_wait_ms, DEFAULT_POST_ACTION_WAIT_MS));
2703
- return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'attach_local_file', `Attached local file to ${selector}.`, {
5319
+ const fileNames = resolved.map((path) => basename(path));
5320
+ return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'attach_local_file', `Attached ${resolved.length} local file(s) to ${selector}.`, {
2704
5321
  selector,
2705
- fileName: basename(resolved),
5322
+ fileCount: resolved.length,
5323
+ fileNames,
5324
+ ...(resolved.length === 1 ? { fileName: fileNames[0] } : {}),
2706
5325
  });
2707
5326
  });
2708
5327
  }
2709
5328
  case 'await_file_transfer': {
2710
5329
  const target = resolveActionTarget(input.repoRoot, input.args);
2711
5330
  const selector = requiredString(input.args.selector, 'selector');
5331
+ const requestedName = stringValue(input.args.suggested_name);
2712
5332
  const downloadDir = stateDir(input.repoRoot, 'downloads');
2713
5333
  mkdirSync(downloadDir, { recursive: true });
2714
- const suggested = stringValue(input.args.suggested_name) ?? `download-${Date.now()}`;
2715
- const safeName = suggested.replace(/[^a-zA-Z0-9._-]+/g, '_');
2716
- const dest = join(downloadDir, safeName);
2717
5334
  return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
2718
5335
  if (connection.provider === 'macos-apple-events') {
2719
5336
  throw new AssistantPluginError('PLUGIN_BROWSER_NATIVE_ACTION_UNSUPPORTED', 'Native Apple Events attachment does not support download capture. Use CDP or a managed browser context.', { retryable: false });
2720
5337
  }
2721
- await page.click(selector, { timeout: positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS) });
2722
- // Best-effort artifact placeholder when Playwright download events are unavailable in mocks.
2723
- if (!existsSync(dest)) writeFileSync(dest, '');
2724
- if (/\.(exe|dmg|pkg|sh|bat|cmd|app)$/i.test(dest)) {
5338
+ if (!page.waitForEvent) {
5339
+ throw new AssistantPluginError('PLUGIN_BROWSER_DOWNLOAD_UNAVAILABLE', 'The selected Playwright page does not expose download events; refusing to fabricate a download artifact.', { retryable: false, details: { provider: connection.provider } });
5340
+ }
5341
+ const timeout = positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS);
5342
+ let download: BrowserDownloadLike;
5343
+ try {
5344
+ [download] = await Promise.all([
5345
+ page.waitForEvent('download', { timeout }),
5346
+ page.click(selector, { timeout }),
5347
+ ]);
5348
+ } catch (error) {
5349
+ throw new AssistantPluginError('PLUGIN_BROWSER_DOWNLOAD_FAILED', 'Browser download did not start successfully after the authorized click.', {
5350
+ retryable: true, details: { selector, cause: error instanceof Error ? error.message : String(error) },
5351
+ });
5352
+ }
5353
+ const browserSuggestedName = download.suggestedFilename();
5354
+ if (isBlockedExecutableArtifactName(browserSuggestedName) || isBlockedExecutableArtifactName(requestedName)) {
5355
+ await download.delete?.().catch(() => undefined);
5356
+ throw new AssistantPluginError('PLUGIN_POLICY_BLOCKED', 'Downloaded executable files are not retained or auto-opened and were discarded.', {
5357
+ retryable: false, details: { suggestedFilename: browserSuggestedName },
5358
+ });
5359
+ }
5360
+ if (download.failure) {
5361
+ const failure = await download.failure();
5362
+ if (failure) {
5363
+ throw new AssistantPluginError('PLUGIN_BROWSER_DOWNLOAD_FAILED', 'Browser reported that the download failed.', { retryable: true, details: { selector, cause: failure } });
5364
+ }
5365
+ }
5366
+ const safeName = safeDownloadArtifactName(requestedName, browserSuggestedName);
5367
+ const dest = uniqueDownloadArtifactPath(downloadDir, safeName);
5368
+ try {
5369
+ await download.saveAs(dest);
5370
+ } catch (error) {
2725
5371
  rmSync(dest, { force: true });
2726
- throw new AssistantPluginError('PLUGIN_POLICY_BLOCKED', 'Downloaded executable files are not auto-opened and were discarded.', { retryable: false });
5372
+ throw new AssistantPluginError('PLUGIN_BROWSER_DOWNLOAD_FAILED', 'Browser download could not be saved into Forge artifact storage.', {
5373
+ retryable: true, details: { selector, cause: error instanceof Error ? error.message : String(error) },
5374
+ });
5375
+ }
5376
+ if (!existsSync(dest)) {
5377
+ throw new AssistantPluginError('PLUGIN_BROWSER_DOWNLOAD_FAILED', 'Browser download save completed without producing an artifact; refusing false success.', { retryable: true, details: { selector } });
2727
5378
  }
2728
5379
  await delay(positiveNumber(input.args.post_action_wait_ms, DEFAULT_POST_ACTION_WAIT_MS));
2729
- const result = await finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'await_file_transfer', `Captured download artifact for ${selector}.`, {
5380
+ return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'await_file_transfer', `Captured download artifact for ${selector}.`, {
2730
5381
  selector,
2731
5382
  download: {
2732
5383
  path: dest,
2733
5384
  relativePath: relative(input.repoRoot, dest),
5385
+ fileName: basename(dest),
5386
+ browserSuggestedName,
2734
5387
  autoOpened: false,
2735
5388
  },
2736
5389
  });
2737
- return result;
2738
5390
  });
2739
5391
  }
2740
5392
  default:
@@ -2757,8 +5409,16 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2757
5409
  }
2758
5410
  }
2759
5411
 
5412
+ export async function executeBrowserPluginAction(input: AssistantPluginActionExecutionInput): Promise<Record<string, unknown>> {
5413
+ return await withBrowserSessionAuthorityContext(
5414
+ { controllerHome: input.controllerHome, repoId: input.repoId },
5415
+ () => executeBrowserPluginActionInternal(input),
5416
+ );
5417
+ }
5418
+
2760
5419
  export const browserPluginAdapter = {
2761
5420
  pluginId: BROWSER_PLUGIN_ID,
2762
5421
  buildManifest: buildBrowserPluginManifest,
2763
5422
  executeAction: executeBrowserPluginAction,
5423
+ resolveAuthorizationContext: resolveBrowserPluginAuthorizationContext,
2764
5424
  };