@moretea-labs/forge 1.7.0 → 1.7.2

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 (175) hide show
  1. package/AGENTS.md +2 -0
  2. package/CHANGELOG.md +26 -0
  3. package/CLAUDE.md +2 -0
  4. package/THIRD_PARTY_NOTICES.md +1 -1
  5. package/agents/openai.yaml +1 -1
  6. package/assets/partials-agents/02-operating-mode.partial.md +2 -0
  7. package/assets/plugin-registry.v1.json +3 -3
  8. package/assets/skill-version.json +2 -2
  9. package/assets/templates/helpers/check-architecture-sync.sh +17 -0
  10. package/assets/templates/helpers/inspect-project-state.ts +8 -6
  11. package/assets/templates/helpers/migrate-workflow-docs.ts +9 -7
  12. package/assets/templates/helpers/workflow-contract.ts +5 -1
  13. package/docs/architecture/CURRENT.md +12 -4
  14. package/docs/forge-plugin-management.md +1 -1
  15. package/docs/operations/controller-performance-and-502.md +19 -0
  16. package/docs/operations/homebrew.md +2 -2
  17. package/package.json +5 -2
  18. package/scripts/architecture-event.ts +2 -820
  19. package/scripts/architecture-queue.sh +2 -645
  20. package/scripts/archive-architecture-request.sh +2 -242
  21. package/scripts/archive-workflow.sh +1 -291
  22. package/scripts/benchmark-work-efficiency.ts +0 -0
  23. package/scripts/capability-config.ts +2 -392
  24. package/scripts/capability-resolver.ts +2 -503
  25. package/scripts/capture-plan.sh +1 -332
  26. package/scripts/check-agent-tooling.sh +2 -1531
  27. package/scripts/check-architecture-sync.sh +2 -302
  28. package/scripts/check-brain-manifest.sh +2 -253
  29. package/scripts/check-context-files.sh +2 -47
  30. package/scripts/check-deploy-sql-order.sh +2 -115
  31. package/scripts/check-runtime-architecture.mjs +243 -16
  32. package/scripts/check-skill-version.ts +3 -191
  33. package/scripts/check-source-duplication.mjs +61 -0
  34. package/scripts/check-stable-baseline.ts +0 -0
  35. package/scripts/check-task-sync.sh +2 -63
  36. package/scripts/check-task-workflow.sh +2 -1000
  37. package/scripts/codex-handoff-resume.sh +2 -267
  38. package/scripts/context-contract-sync.sh +2 -514
  39. package/scripts/contract-run.ts +2 -463
  40. package/scripts/contract-worktree.sh +1 -702
  41. package/scripts/ensure-task-workflow.sh +1 -1192
  42. package/scripts/inspect-project-state.ts +4 -279
  43. package/scripts/lib/project-init-lib.sh +7 -2
  44. package/scripts/lib/run-forge-helper.sh +19 -0
  45. package/scripts/lib/run-forge-helper.ts +26 -0
  46. package/scripts/migrate-project-template.sh +1 -33
  47. package/scripts/migrate-workflow-docs.ts +4 -412
  48. package/scripts/new-plan.sh +1 -180
  49. package/scripts/new-spec.sh +1 -47
  50. package/scripts/new-sprint.sh +1 -46
  51. package/scripts/plan-to-todo.sh +1 -771
  52. package/scripts/prepare-codex-handoff.sh +2 -201
  53. package/scripts/prepare-handoff.sh +1 -78
  54. package/scripts/refresh-current-status.sh +2 -467
  55. package/scripts/run-governed-gate.ts +3 -0
  56. package/scripts/select-agent-context-blocks.sh +2 -64
  57. package/scripts/ship-worktrees.sh +1 -531
  58. package/scripts/sprint-backlog.sh +1 -834
  59. package/scripts/stage-runtime-release.ts +2 -1
  60. package/scripts/summarize-failures.sh +2 -123
  61. package/scripts/switch-plan.sh +1 -150
  62. package/scripts/sync-brain-docs.sh +2 -379
  63. package/scripts/sync-generated-authority.mjs +175 -0
  64. package/scripts/verify-contract.sh +2 -593
  65. package/scripts/verify-sprint.sh +1 -500
  66. package/scripts/workflow-contract.ts +4 -201
  67. package/scripts/workstream-sync.sh +2 -261
  68. package/src/cli/agent-jobs/executor-health.ts +1 -1
  69. package/src/cli/commands/plugin.ts +12 -3
  70. package/src/cli/controller/check-runner.ts +7 -2
  71. package/src/cli/controller/context-pack.ts +46 -21
  72. package/src/cli/github/contracts.ts +16 -0
  73. package/src/cli/github/github.ts +2 -16
  74. package/src/cli/github/session.ts +2 -1
  75. package/src/cli/local-bridge/facade-api.ts +37 -47
  76. package/src/cli/local-bridge/server.ts +2 -2
  77. package/src/cli/mcp/legacy-tool-service.ts +10 -33
  78. package/src/cli/mcp/post-finalize-work-attribution.ts +150 -0
  79. package/src/cli/mcp/repository-tools.ts +125 -101
  80. package/src/cli/mcp/server.ts +57 -13
  81. package/src/cli/mcp/tool-contract.ts +14 -0
  82. package/src/cli/mcp/toolset.ts +2 -2
  83. package/src/cli/mcp/transports/http.ts +23 -0
  84. package/src/cli/repositories/command-classifier.ts +2 -2
  85. package/src/cli/repositories/command-executor.ts +24 -6
  86. package/src/cli/repositories/repository-snapshot.ts +5 -2
  87. package/src/cli/repositories/structured-git.ts +57 -3
  88. package/src/cli/repository/inspector.ts +11 -3
  89. package/src/runtime/context/generic-lsp-provider.ts +1 -1
  90. package/src/runtime/context/semantic-navigation-contract.ts +61 -0
  91. package/src/runtime/context/semantic-navigation.ts +9 -61
  92. package/src/runtime/control-plane/execution/direct-edit-work-completion.ts +69 -29
  93. package/src/runtime/control-plane/execution/repository-work-attribution.ts +106 -0
  94. package/src/runtime/control-plane/execution/retained-work-resume.ts +203 -0
  95. package/src/runtime/control-plane/execution/work-completion-authority.ts +58 -0
  96. package/src/runtime/control-plane/execution/work-evidence-policy.ts +200 -0
  97. package/src/runtime/control-plane/execution/work-execution-support.ts +386 -0
  98. package/src/runtime/control-plane/execution/work-finalization-service.ts +1913 -0
  99. package/src/runtime/control-plane/execution/work-handle-authority.ts +150 -0
  100. package/src/runtime/control-plane/execution/work-handle-store.ts +1 -1
  101. package/src/runtime/control-plane/execution/work-operation-service.ts +468 -0
  102. package/src/runtime/control-plane/execution/work-path-scope.ts +37 -0
  103. package/src/runtime/control-plane/execution/work-preparation-service.ts +479 -0
  104. package/src/runtime/control-plane/execution/work-process-evidence.ts +129 -0
  105. package/src/runtime/control-plane/execution/work-task-receipt.ts +4 -43
  106. package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +13 -3
  107. package/src/runtime/control-plane/execution/work-verification-context.ts +56 -0
  108. package/src/runtime/control-plane/execution/work-verification-service.ts +388 -0
  109. package/src/runtime/control-plane/execution/work-verification-snapshot.ts +5 -1
  110. package/src/runtime/control-plane/facade/controller-round-relay.ts +164 -37
  111. package/src/runtime/control-plane/facade/controller-session-store.ts +183 -24
  112. package/src/runtime/control-plane/facade/goal-workloop-access.ts +15 -1
  113. package/src/runtime/control-plane/facade/goal-workloop.ts +329 -148
  114. package/src/runtime/control-plane/facade/index.ts +2 -0
  115. package/src/runtime/control-plane/facade/plan-contract-store.ts +90 -22
  116. package/src/runtime/control-plane/facade/repository-work-admission.ts +147 -0
  117. package/src/runtime/control-plane/facade/requirement-authority.ts +83 -0
  118. package/src/runtime/control-plane/facade/types.ts +68 -5
  119. package/src/runtime/control-plane/facade/work-contract-store.ts +3 -209
  120. package/src/runtime/control-plane/facade/work-state-machine.ts +250 -0
  121. package/src/runtime/control-plane/global-scheduler/maintenance.ts +14 -7
  122. package/src/runtime/control-plane/global-scheduler/scheduler.ts +5 -1
  123. package/src/runtime/control-plane/launcher/chatgpt-work-binding-store.ts +13 -0
  124. package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +15 -6
  125. package/src/runtime/control-plane/launcher/launch-reservation-store.ts +54 -5
  126. package/src/runtime/control-plane/launcher/thin-launcher.ts +56 -7
  127. package/src/runtime/control-plane/persistence/requirement-store.ts +0 -12
  128. package/src/runtime/control-plane/repo-actor/registry.ts +4 -2
  129. package/src/runtime/control-plane/routing/route-policy.ts +18 -6
  130. package/src/runtime/control-plane/routing/workspace-admission.ts +70 -0
  131. package/src/runtime/evidence/process-check-execution.ts +18 -0
  132. package/src/runtime/execution/jobs/child-reference.ts +6 -2
  133. package/src/runtime/execution/process-runtime/check-result.ts +53 -18
  134. package/src/runtime/execution/process-runtime/check-runner-sidecar.ts +7 -1
  135. package/src/runtime/execution/process-runtime/command-facade.ts +39 -17
  136. package/src/runtime/execution/process-runtime/lightweight-managed.ts +20 -3
  137. package/src/runtime/execution/process-runtime/persisted-check.ts +4 -1
  138. package/src/runtime/execution/process-runtime/runtime.ts +10 -0
  139. package/src/runtime/execution/process-runtime/types.ts +2 -12
  140. package/src/runtime/execution/work-activity.ts +21 -0
  141. package/src/runtime/execution/workers/executor.ts +18 -13
  142. package/src/runtime/gateway/mcp/execution-tools.ts +35 -2930
  143. package/src/runtime/gateway/mcp/legacy-ios-tool-adapter.ts +61 -0
  144. package/src/runtime/gateway/mcp/router.ts +2 -2
  145. package/src/runtime/gateway/mcp/runtime-tool-definitions.ts +21 -2
  146. package/src/runtime/gateway/mcp/runtime-tools.ts +815 -1015
  147. package/src/runtime/plugins/app-store-connect-adapter.ts +93 -28
  148. package/src/runtime/plugins/browser-adapter.ts +256 -1083
  149. package/src/runtime/plugins/browser-handoff-host.ts +2 -25
  150. package/src/runtime/plugins/browser-handoff.ts +18 -3
  151. package/src/runtime/plugins/browser-macos-bridge.ts +121 -16
  152. package/src/runtime/plugins/browser-manifest-surface.ts +603 -0
  153. package/src/runtime/plugins/browser-native-session-service.ts +222 -0
  154. package/src/runtime/plugins/browser-runtime-contract.ts +2 -0
  155. package/src/runtime/plugins/browser-runtime.ts +10 -0
  156. package/src/runtime/plugins/browser-session-store.ts +121 -0
  157. package/src/runtime/plugins/browser-session-types.ts +98 -0
  158. package/src/runtime/plugins/desktop-operator-registration.ts +4 -4
  159. package/src/runtime/plugins/external-adapter.ts +92 -58
  160. package/src/runtime/plugins/macos-capability-broker.ts +0 -8
  161. package/src/runtime/plugins/store.ts +66 -9
  162. package/src/runtime/plugins/types.ts +3 -1
  163. package/src/runtime/recovery/maintenance-executor.ts +61 -25
  164. package/src/runtime/root/package-connector-service.ts +46 -4
  165. package/src/runtime/root/package-runtime-release.ts +3 -2
  166. package/src/runtime/root/package-runtime-service.ts +48 -3
  167. package/src/runtime/root/release-manifest.ts +137 -16
  168. package/src/runtime/root/release-materialize.ts +109 -35
  169. package/src/runtime/root/types.ts +10 -0
  170. package/src/runtime/standalone-recovery/core.ts +425 -194
  171. package/src/runtime/standalone-recovery/entry.ts +1 -1
  172. package/src/runtime/standalone-recovery/installer.ts +1 -1
  173. package/src/runtime/workflow/schedules/engine.ts +113 -87
  174. package/src/runtime/workflow/schedules/settlement.ts +26 -23
  175. package/src/runtime/workflow/schedules/store.ts +79 -10
@@ -1,22 +1,25 @@
1
1
  import { createHash, randomUUID } from 'crypto';
2
2
  import { createRequire } from 'module';
3
- import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'fs';
3
+ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'fs';
4
4
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'path';
5
- import { readBrowserBinding } from '../../cli/chatgpt-browser/binding';
6
5
  import type {
7
- AssistantPluginActionDescriptor,
8
6
  AssistantPluginActionExecutionInput,
9
7
  AssistantPluginAuthorizationContext,
10
- AssistantPluginCapability,
11
8
  AssistantPluginHealth,
12
9
  AssistantPluginManifest,
13
- AssistantPluginPermissionScope,
14
10
  } from './types';
15
11
  import { AssistantPluginError, toAssistantPluginError } from './errors';
12
+ import {
13
+ browserActions,
14
+ browserCapabilities,
15
+ browserPermissions,
16
+ } from './browser-manifest-surface';
16
17
  import { executeBrowserActionThroughNode, shouldUseBrowserNodeBridge } from './browser-node-bridge';
17
18
  import { BrowserProviderUnavailableBeforeActionError } from './browser-provider-registry';
19
+ import { MAX_BROWSER_CDP_ENDPOINT_CANDIDATES } from './browser-runtime-contract';
18
20
  import {
19
21
  ALL_BROWSER_PROVIDER_CAPABILITIES,
22
+ browserActionCanReplayAfterDispatch as canReplayBrowserActionAfterDispatch,
20
23
  executeBrowserRuntimeAction,
21
24
  invalidateBrowserRuntime,
22
25
  } from './browser-runtime';
@@ -24,11 +27,7 @@ import { getExternalPluginAdapter } from './external-adapter';
24
27
  import {
25
28
  currentBrowserSessionAuthorityContext,
26
29
  DEFAULT_BROWSER_SESSION_LIST_LIMIT,
27
- findBrowserSession as findBrowserSessionAuthority,
28
- listAllBrowserSessionsForRepository,
29
30
  MAX_BROWSER_SESSION_LIST_LIMIT,
30
- saveBrowserSession as saveBrowserSessionAuthority,
31
- tombstoneBrowserSession,
32
31
  withBrowserSessionAuthorityContext,
33
32
  } from './browser-session-authority';
34
33
  import {
@@ -45,6 +44,7 @@ import {
45
44
  macOsActiveBrowserAttachSupported,
46
45
  macOsBrowserJavaScriptAutomationDisabled,
47
46
  type MacOsBrowserAttachAttempt,
47
+ type MacOsBrowserCreateTabEvidence,
48
48
  type MacOsBrowserProduct,
49
49
  type MacOsBrowserTabRef,
50
50
  } from './browser-macos-bridge';
@@ -56,31 +56,58 @@ import {
56
56
  isRuntimeManagedBrowserHandoff,
57
57
  listBrowserHandoffs,
58
58
  resolveRuntimeManagedBrowserHandoff,
59
- resumeBrowserHandoff,
59
+ resumeBrowserHandoffAndWait,
60
60
  startBrowserHandoff,
61
61
  startRuntimeManagedBrowserHandoff,
62
62
  } from './browser-handoff';
63
+ import {
64
+ browserStateDir,
65
+ findBrowserSession,
66
+ listSavedBrowserSessions,
67
+ removeBrowserSession,
68
+ requireBrowserSession,
69
+ saveBrowserSession,
70
+ } from './browser-session-store';
71
+ import {
72
+ closeTrackedNativeOwnedSession,
73
+ inspectNativeOwnedSessions,
74
+ nativeTabInventoryUnsupported,
75
+ nativeOwnedAliasSessionIds,
76
+ } from './browser-native-session-service';
77
+ export { browserActionCanReplayAfterDispatch } from './browser-runtime';
78
+ import type {
79
+ BrowserCdpAttachFallback,
80
+ BrowserConnectionFallback,
81
+ BrowserMode,
82
+ BrowserProfileMode,
83
+ BrowserSessionResumeDiagnostic,
84
+ BrowserSessionState,
85
+ BrowserSessionConnectionState,
86
+ BrowserSessionInventoryItem,
87
+ BrowserTabMatchReason,
88
+ BrowserTabResumeState,
89
+ CdpAttachAttempt,
90
+ } from './browser-session-types';
63
91
 
64
92
  const BROWSER_PLUGIN_ID = 'browser';
65
93
  const CONFIG_ROOT = '.forge/plugins';
66
- const STATE_ROOT = '.forge/browser';
67
94
  const DEFAULT_TIMEOUT_MS = 30_000;
68
95
  const DEFAULT_MAX_TEXT_CHARS = 20_000;
69
96
  const DEFAULT_POST_ACTION_WAIT_MS = 750;
70
97
  const DEFAULT_CDP_DISCOVERY_TIMEOUT_MS = 1_500;
71
98
  const MAX_CDP_DISCOVERY_TIMEOUT_MS = 5_000;
72
- const MAX_CDP_ENDPOINT_CANDIDATES = 5;
73
99
 
74
100
  type WaitUntil = 'load' | 'domcontentloaded' | 'networkidle';
75
101
  type WaitForSelectorState = 'attached' | 'detached' | 'visible' | 'hidden';
76
- type BrowserMode = 'attach_preferred' | 'managed_persistent' | 'isolated';
77
- type BrowserProfileMode = 'repo_local' | 'custom';
78
102
  type BrowserChannel = 'chromium' | 'chrome' | 'chrome-beta' | 'chrome-dev' | 'chrome-canary';
79
- type BrowserCdpAttachFallback = 'managed_persistent' | 'fail_closed';
80
103
  type BrowserNativeAttachMode = 'auto' | 'disabled';
81
104
 
105
+ const CURRENT_BROWSER_CONFIG_SCHEMA_VERSION = 2 as const;
106
+ const DEFAULT_USER_BROWSER_CHANNEL: BrowserChannel = 'chrome';
107
+ const DEFAULT_USER_NATIVE_BROWSER_CANDIDATES: MacOsBrowserProduct[] = ['chrome'];
108
+
82
109
  interface BrowserPluginConfig {
83
- schemaVersion: 1;
110
+ schemaVersion: 2;
84
111
  enabled: boolean;
85
112
  provider: 'playwright';
86
113
  browserMode?: BrowserMode;
@@ -98,51 +125,8 @@ interface BrowserPluginConfig {
98
125
  defaultTimeoutMs?: number;
99
126
  }
100
127
 
101
- interface BrowserTabResumeState {
102
- key: string;
103
- index: number;
104
- url: string;
105
- title?: string;
106
- matchedBy: BrowserTabMatchReason;
107
- inventoryCount: number;
108
- capturedAt: string;
109
- ownership?: 'plugin_owned' | 'user_owned';
110
- ownerToken?: string;
111
- windowId?: string;
112
- tabId?: string;
113
- }
114
-
115
- interface BrowserSessionConnectionState {
116
- mode: BrowserMode;
117
- activeMode: BrowserMode;
118
- provider: 'playwright-cdp' | 'playwright-persistent-context' | 'macos-apple-events';
119
- endpoint?: string;
120
- browserVersion?: string;
121
- browserProduct?: MacOsBrowserProduct;
122
- nativeBrowserCandidates?: MacOsBrowserProduct[];
123
- fallback?: BrowserConnectionFallback;
124
- tab?: BrowserTabResumeState;
125
- sessionResume?: BrowserSessionResumeDiagnostic;
126
- }
127
-
128
- interface BrowserSessionState {
129
- schemaVersion: 1;
130
- sessionId: string;
131
- url: string;
132
- title?: string;
133
- createdAt: string;
134
- updatedAt: string;
135
- browser?: BrowserSessionConnectionState;
136
- }
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;
128
+ type PersistedBrowserPluginConfig = Partial<Omit<BrowserPluginConfig, 'schemaVersion'>> & {
129
+ schemaVersion?: 1 | 2;
146
130
  };
147
131
 
148
132
  type BrowserSessionInventory = {
@@ -426,16 +410,12 @@ function configPath(repoRoot: string): string {
426
410
  return join(repoRoot, CONFIG_ROOT, 'browser.json');
427
411
  }
428
412
 
429
- function stateDir(repoRoot: string, name: 'sessions' | 'screenshots' | 'profiles' | 'downloads' | 'diagnostics'): string {
430
- return join(repoRoot, STATE_ROOT, name);
431
- }
432
-
433
413
  function defaultProfileDir(repoRoot: string): string {
434
- return join(stateDir(repoRoot, 'profiles'), 'default');
414
+ return join(browserStateDir(repoRoot, 'profiles'), 'default');
435
415
  }
436
416
 
437
417
  function isolatedProfileDir(repoRoot: string, sessionId: string): string {
438
- return join(stateDir(repoRoot, 'profiles'), 'isolated', sessionId.replace(/[^a-zA-Z0-9_-]/g, '_'));
418
+ return join(browserStateDir(repoRoot, 'profiles'), 'isolated', sessionId.replace(/[^a-zA-Z0-9_-]/g, '_'));
439
419
  }
440
420
 
441
421
  function resolveConfiguredPath(repoRoot: string, value: string): string {
@@ -467,30 +447,47 @@ function resolveProfileSelection(repoRoot: string, profileDir: string, profileDi
467
447
  };
468
448
  }
469
449
 
470
- function normalizeConfig(raw: Partial<BrowserPluginConfig>): BrowserPluginConfig {
450
+ function normalizeConfig(raw: PersistedBrowserPluginConfig): BrowserPluginConfig {
471
451
  const normalizedProfileDir = stringValue(raw.profileDir);
472
452
  return {
473
- schemaVersion: 1,
453
+ schemaVersion: CURRENT_BROWSER_CONFIG_SCHEMA_VERSION,
474
454
  enabled: raw.enabled === true,
475
455
  provider: 'playwright',
476
456
  browserMode: browserMode(raw.browserMode) ?? 'attach_preferred',
477
457
  profileMode: browserProfileMode(raw.profileMode) ?? (normalizedProfileDir ? 'custom' : 'repo_local'),
478
458
  profileDir: normalizedProfileDir,
479
459
  profileDirectory: stringValue(raw.profileDirectory),
480
- browserChannel: browserChannel(raw.browserChannel) ?? 'chromium',
460
+ browserChannel: browserChannel(raw.browserChannel) ?? DEFAULT_USER_BROWSER_CHANNEL,
481
461
  executablePath: stringValue(raw.executablePath),
482
462
  cdpEndpoint: stringValue(raw.cdpEndpoint),
483
- cdpEndpointCandidates: stringList(raw.cdpEndpointCandidates)?.slice(0, MAX_CDP_ENDPOINT_CANDIDATES),
463
+ cdpEndpointCandidates: stringList(raw.cdpEndpointCandidates)?.slice(0, MAX_BROWSER_CDP_ENDPOINT_CANDIDATES),
484
464
  cdpDiscoveryTimeoutMs: typeof raw.cdpDiscoveryTimeoutMs === 'number'
485
465
  ? Math.min(positiveNumber(raw.cdpDiscoveryTimeoutMs, DEFAULT_CDP_DISCOVERY_TIMEOUT_MS), MAX_CDP_DISCOVERY_TIMEOUT_MS)
486
466
  : undefined,
487
467
  cdpAttachFallback: browserCdpAttachFallback(raw.cdpAttachFallback) ?? 'fail_closed',
488
468
  nativeAttachMode: browserNativeAttachMode(raw.nativeAttachMode) ?? 'auto',
489
- nativeBrowserCandidates: browserProductList(raw.nativeBrowserCandidates) ?? ['chrome', 'vivaldi'],
469
+ nativeBrowserCandidates: browserProductList(raw.nativeBrowserCandidates) ?? [...DEFAULT_USER_NATIVE_BROWSER_CANDIDATES],
490
470
  defaultTimeoutMs: typeof raw.defaultTimeoutMs === 'number' ? positiveNumber(raw.defaultTimeoutMs, DEFAULT_TIMEOUT_MS) : undefined,
491
471
  };
492
472
  }
493
473
 
474
+ function migrateLegacyRepositoryConfig(raw: PersistedBrowserPluginConfig): PersistedBrowserPluginConfig {
475
+ if (raw.schemaVersion !== 1) return raw;
476
+ return {
477
+ schemaVersion: CURRENT_BROWSER_CONFIG_SCHEMA_VERSION,
478
+ enabled: raw.enabled,
479
+ provider: 'playwright',
480
+ browserMode: 'attach_preferred',
481
+ profileMode: 'repo_local',
482
+ browserChannel: DEFAULT_USER_BROWSER_CHANNEL,
483
+ cdpAttachFallback: 'fail_closed',
484
+ nativeAttachMode: 'auto',
485
+ nativeBrowserCandidates: [...DEFAULT_USER_NATIVE_BROWSER_CANDIDATES],
486
+ defaultTimeoutMs: raw.defaultTimeoutMs,
487
+ cdpDiscoveryTimeoutMs: raw.cdpDiscoveryTimeoutMs,
488
+ };
489
+ }
490
+
494
491
  function readJson<T>(path: string): T | undefined {
495
492
  try {
496
493
  return JSON.parse(readFileSync(path, 'utf-8')) as T;
@@ -504,71 +501,23 @@ function writeJson(path: string, value: unknown): void {
504
501
  writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, 'utf-8');
505
502
  }
506
503
 
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
-
550
504
  function loadConfig(repoRoot: string): BrowserPluginConfig {
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);
505
+ const persisted = readJson<PersistedBrowserPluginConfig>(configPath(repoRoot));
506
+ if (!persisted) return normalizeConfig({});
507
+ return normalizeConfig(migrateLegacyRepositoryConfig(persisted));
559
508
  }
560
509
 
561
510
  async function resolveBrowserPluginAuthorizationContextInternal(
562
511
  input: AssistantPluginActionExecutionInput,
563
512
  ): Promise<AssistantPluginAuthorizationContext | undefined> {
564
- const action = actions().find((entry) => entry.actionId === input.actionId);
513
+ const action = browserActions().find((entry) => entry.actionId === input.actionId);
565
514
  if (!action || action.confirmation !== 'authorization' || !action.scopes.includes('browser.interact')) return undefined;
566
515
 
567
516
  const persistedConfig = loadConfig(input.repoRoot);
568
517
  const sessionId = stringValue(input.args.session_id);
569
518
  const authorizationBrowserMode = parseBrowserModeInput(input.args.browser_mode) ?? persistedConfig.browserMode;
570
519
  if (!sessionId || authorizationBrowserMode === 'isolated') return undefined;
571
- const session = findSession(input.repoRoot, sessionId);
520
+ const session = findBrowserSession(input.repoRoot, sessionId);
572
521
  const config = effectiveBrowserActionConfig(persistedConfig, input.args, session);
573
522
  const connection = session?.browser;
574
523
  if (!session || !connection || connection.activeMode === 'isolated') return undefined;
@@ -630,73 +579,6 @@ function saveConfig(repoRoot: string, patch: Partial<BrowserPluginConfig>): Brow
630
579
  return next;
631
580
  }
632
581
 
633
- function sessionPath(repoRoot: string, sessionId: string): string {
634
- return join(stateDir(repoRoot, 'sessions'), `${sessionId.replace(/[^a-zA-Z0-9_-]/g, '_')}.json`);
635
- }
636
-
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
- };
652
- }
653
-
654
- function findSession(repoRoot: string, sessionId: string): BrowserSessionState | undefined {
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 });
690
- }
691
-
692
- function loadSession(repoRoot: string, sessionId: string): BrowserSessionState {
693
- const state = findSession(repoRoot, sessionId);
694
- if (!state) {
695
- throw new AssistantPluginError('PLUGIN_SESSION_NOT_FOUND', `Browser session not found: ${sessionId}`, { retryable: false });
696
- }
697
- return state;
698
- }
699
-
700
582
  function parsedAbsoluteUrl(value: unknown): { raw: string; parsed: URL } {
701
583
  const raw = stringValue(value);
702
584
  if (!raw) throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'url is required.', { retryable: false });
@@ -840,7 +722,7 @@ function cdpEndpoints(config: BrowserPluginConfig): string[] {
840
722
  return Array.from(new Set([
841
723
  ...(config.cdpEndpoint ? [config.cdpEndpoint] : []),
842
724
  ...(config.cdpEndpointCandidates ?? []),
843
- ].map((entry) => entry.trim()).filter(Boolean))).slice(0, MAX_CDP_ENDPOINT_CANDIDATES);
725
+ ].map((entry) => entry.trim()).filter(Boolean))).slice(0, MAX_BROWSER_CDP_ENDPOINT_CANDIDATES);
844
726
  }
845
727
 
846
728
  function isLoopbackHostname(hostname: string): boolean {
@@ -878,8 +760,8 @@ function validateConfig(config: BrowserPluginConfig): string[] {
878
760
  if (config.browserChannel && config.browserChannel !== 'chromium' && config.executablePath) {
879
761
  errors.push('browserChannel and executablePath cannot both be set.');
880
762
  }
881
- if (endpoints.length > MAX_CDP_ENDPOINT_CANDIDATES) {
882
- errors.push(`At most ${MAX_CDP_ENDPOINT_CANDIDATES} CDP endpoint candidates are supported.`);
763
+ if (endpoints.length > MAX_BROWSER_CDP_ENDPOINT_CANDIDATES) {
764
+ errors.push(`At most ${MAX_BROWSER_CDP_ENDPOINT_CANDIDATES} CDP endpoint candidates are supported.`);
883
765
  }
884
766
  for (const endpoint of endpoints) {
885
767
  const error = cdpEndpointValidationError(endpoint);
@@ -904,7 +786,9 @@ function configWarnings(config: BrowserPluginConfig): string[] {
904
786
  }
905
787
  if (config.browserMode === 'attach_preferred' && cdpEndpoints(config).length === 0) {
906
788
  if (config.nativeAttachMode === 'auto' && macOsActiveBrowserAttachSupported()) {
907
- warnings.push('browserMode=attach_preferred has no configured CDP endpoint; macOS active-browser attach will be attempted before managed fallback.');
789
+ warnings.push(config.cdpAttachFallback === 'fail_closed'
790
+ ? 'browserMode=attach_preferred has no configured CDP endpoint; configured native-browser attach will be attempted and managed fallback is disabled.'
791
+ : 'browserMode=attach_preferred has no configured CDP endpoint; configured native-browser attach will be attempted before explicit managed fallback.');
908
792
  } else {
909
793
  warnings.push('browserMode=attach_preferred has no configured CDP endpoint; actions will use the configured fallback policy.');
910
794
  }
@@ -977,7 +861,7 @@ function resolveActionTarget(repoRoot: string, args: Record<string, unknown>): B
977
861
  throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'Provide either url or session_id.', { retryable: false });
978
862
  }
979
863
  if (providedSessionId) {
980
- const existingSession = loadSession(repoRoot, providedSessionId);
864
+ const existingSession = requireBrowserSession(repoRoot, providedSessionId);
981
865
  const nativeUserOwned = isExplicitNativeUserOwnedSession(existingSession);
982
866
  const sessionUrl = normalizedSavedSessionUrl(existingSession);
983
867
  if (directUrl) {
@@ -1006,7 +890,7 @@ function sessionFromPage(target: BrowserActionTarget, pageUrl: string, title: st
1006
890
  }
1007
891
 
1008
892
  function screenshotFilePath(repoRoot: string, actionId: string, sessionId: string, url: string): string {
1009
- const screenshotDir = stateDir(repoRoot, 'screenshots');
893
+ const screenshotDir = browserStateDir(repoRoot, 'screenshots');
1010
894
  mkdirSync(screenshotDir, { recursive: true });
1011
895
  const digest = createHash('sha256').update(url).digest('hex').slice(0, 10);
1012
896
  const actionLabel = actionId.replace(/[^a-zA-Z0-9_-]/g, '_');
@@ -1100,8 +984,6 @@ function selectedProfile(config: BrowserPluginConfig, repoRoot: string, activeMo
1100
984
  };
1101
985
  }
1102
986
 
1103
- type BrowserTabMatchReason = 'owned_token' | 'recovered_tab' | 'saved_url_title' | 'saved_url' | 'exact_url' | 'blank' | 'new_page';
1104
-
1105
987
  const BROWSER_OWNER_PREFIX = 'forge-browser-owned:';
1106
988
 
1107
989
  interface BrowserTabInventoryEntry {
@@ -1116,30 +998,6 @@ function ownerTokenForSession(sessionId: string): string {
1116
998
  return `${BROWSER_OWNER_PREFIX}${createHash('sha256').update(sessionId).digest('hex').slice(0, 24)}`;
1117
999
  }
1118
1000
 
1119
- interface BrowserSessionResumeDiagnostic {
1120
- sessionId: string;
1121
- status: 'matched' | 'stale_tab' | 'no_saved_tab';
1122
- reason: string;
1123
- savedTab?: Pick<BrowserTabResumeState, 'key' | 'url' | 'title' | 'index'>;
1124
- }
1125
-
1126
- interface BrowserConnectionFallback {
1127
- policy: BrowserCdpAttachFallback;
1128
- from: 'attach_preferred';
1129
- to: 'managed_persistent';
1130
- reason: string;
1131
- attempts: CdpAttachAttempt[];
1132
- nativeAttempts?: MacOsBrowserAttachAttempt[];
1133
- }
1134
-
1135
- interface CdpAttachAttempt {
1136
- endpoint: string;
1137
- discoveredEndpoint?: string;
1138
- probeUrl?: string;
1139
- browserVersion?: string;
1140
- error?: string;
1141
- }
1142
-
1143
1001
  interface BrowserConnectionSummary {
1144
1002
  requestedMode: BrowserMode;
1145
1003
  mode: BrowserMode;
@@ -1290,6 +1148,27 @@ export function nativeReplacementUrlMatchesTarget(requestedUrl: string, actualUr
1290
1148
  }
1291
1149
  }
1292
1150
 
1151
+ export function nativeReplacementAssignmentProvenanceProvesTarget(
1152
+ evidence: MacOsBrowserCreateTabEvidence | undefined,
1153
+ requestedUrl: string,
1154
+ replacementRef: MacOsBrowserTabRef,
1155
+ ): boolean {
1156
+ if (!evidence || evidence.refSource !== 'structured' || !evidence.navigation) return false;
1157
+ if (evidence.ref.windowId !== replacementRef.windowId || evidence.ref.tabId !== replacementRef.tabId) return false;
1158
+ return evidence.navigation.provenanceVersion === 1
1159
+ && evidence.navigation.assignmentAccepted === true
1160
+ && evidence.navigation.requestedUrl === requestedUrl;
1161
+ }
1162
+
1163
+ export function nativeReplacementPostAssignmentUrlMatchesTarget(
1164
+ requestedUrl: string,
1165
+ actualUrl: string,
1166
+ assignmentProvenanceProven: boolean,
1167
+ ): boolean {
1168
+ if (nativeReplacementUrlMatchesTarget(requestedUrl, actualUrl)) return true;
1169
+ return assignmentProvenanceProven && sameOrigin(actualUrl, requestedUrl);
1170
+ }
1171
+
1293
1172
  export async function settleNativeCreatedPageIdentity(
1294
1173
  page: {
1295
1174
  waitForLoadState: (state?: string, options?: Record<string, unknown>) => Promise<void>;
@@ -1297,9 +1176,29 @@ export async function settleNativeCreatedPageIdentity(
1297
1176
  },
1298
1177
  state: string,
1299
1178
  timeoutMs: number,
1179
+ target?: { requestedUrl: string; previousUrl?: string },
1300
1180
  ): Promise<{ url: string; title: string }> {
1181
+ const deadline = Date.now() + Math.max(0, timeoutMs);
1301
1182
  await page.waitForLoadState(state, { timeout: timeoutMs, requireHttpUrl: true });
1302
- return await page.identity();
1183
+ let identity = await page.identity();
1184
+ if (!target || nativeReplacementUrlMatchesTarget(target.requestedUrl, identity.url)) return identity;
1185
+
1186
+ const previousUrl = target.previousUrl?.trim();
1187
+ if (!previousUrl || comparableUrl(identity.url) !== comparableUrl(previousUrl)) return identity;
1188
+
1189
+ // Native Chrome/Vivaldi can briefly expose the obsolete tab URL on the newly
1190
+ // created stable replacement ref before the create-tab navigation commits.
1191
+ // Only that exact previous URL is treated as transitional. A different HTTP(S)
1192
+ // URL remains an immediate postcondition failure, and authority never moves to
1193
+ // another tab while this bounded settlement loop runs.
1194
+ while (Date.now() < deadline) {
1195
+ const remainingMs = deadline - Date.now();
1196
+ await new Promise((resolve) => setTimeout(resolve, Math.min(100, remainingMs)));
1197
+ identity = await page.identity();
1198
+ if (nativeReplacementUrlMatchesTarget(target.requestedUrl, identity.url)) return identity;
1199
+ if (comparableUrl(identity.url) !== comparableUrl(previousUrl)) return identity;
1200
+ }
1201
+ return identity;
1303
1202
  }
1304
1203
 
1305
1204
  export function nativeReplacementMismatchDiagnostic(input: {
@@ -2107,7 +2006,7 @@ async function openNativeAttachedContext(
2107
2006
  ): Promise<{ handle?: BrowserOpenHandle; attempts: MacOsBrowserAttachAttempt[] }> {
2108
2007
  if (config.nativeAttachMode === 'disabled') return { attempts: [] };
2109
2008
  if (!target.existingSession && !stringValue(args.session_id)) {
2110
- await inspectNativeOwnedSessions(repoRoot, config, listSavedSessions(repoRoot), { pruneDead: true });
2009
+ await inspectNativeOwnedSessions({ repoRoot, savedSessions: listSavedSessions(repoRoot), timeoutMs: config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS, pruneDead: true });
2111
2010
  }
2112
2011
  const timeout = positiveNumber(args.timeout_ms, config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS);
2113
2012
  const savedTab = target.existingSession?.browser?.tab;
@@ -2164,7 +2063,9 @@ async function openNativeAttachedContext(
2164
2063
  savedTab,
2165
2064
  };
2166
2065
  } catch (error) {
2167
- if (error instanceof AssistantPluginError && error.code === 'PLUGIN_BROWSER_NATIVE_TAB_IDENTITY_UNPROVEN') {
2066
+ if (error instanceof AssistantPluginError
2067
+ && error.code === 'PLUGIN_BROWSER_NATIVE_TAB_IDENTITY_UNPROVEN'
2068
+ && savedOwnership === 'user_owned') {
2168
2069
  throw error;
2169
2070
  }
2170
2071
  if (savedOwnership === 'user_owned') {
@@ -2227,7 +2128,7 @@ async function openNativeAttachedContext(
2227
2128
  }
2228
2129
 
2229
2130
  if (!discovered?.attachment) {
2230
- const candidates = savedProduct ? [savedProduct] : (config.nativeBrowserCandidates ?? ['vivaldi', 'chrome']);
2131
+ const candidates = savedProduct ? [savedProduct] : (config.nativeBrowserCandidates ?? [...DEFAULT_USER_NATIVE_BROWSER_CANDIDATES]);
2231
2132
  discovered = await discoverMacOsBrowserAttachment(candidates, Math.min(timeout, MAX_CDP_DISCOVERY_TIMEOUT_MS));
2232
2133
  }
2233
2134
  if (!discovered.attachment) return { attempts: discovered.attempts };
@@ -2243,11 +2144,37 @@ async function openNativeAttachedContext(
2243
2144
  } catch (error) {
2244
2145
  if (!nativeBackgroundNavigationRequiresReplacement(error) || effectiveOwnership === 'user_owned' || !obsoleteRef) throw error;
2245
2146
  const replacement = await createMacOsBrowserOwnedPage(activeAttachment, target.url, timeout) as unknown as PageLike;
2246
- const replacementRef = (replacement as unknown as { tabRef?: () => MacOsBrowserTabRef | undefined }).tabRef?.();
2147
+ const replacementPage = replacement as unknown as {
2148
+ tabRef?: () => MacOsBrowserTabRef | undefined;
2149
+ creationEvidence?: () => MacOsBrowserCreateTabEvidence | undefined;
2150
+ };
2151
+ const replacementRef = replacementPage.tabRef?.();
2247
2152
  if (!replacementRef) {
2248
2153
  throw new AssistantPluginError('PLUGIN_BROWSER_NATIVE_OWNERSHIP_MISSING', 'Native replacement tab did not return a stable plugin-owned tab reference.', { retryable: true });
2249
2154
  }
2155
+ const creationEvidence = replacementPage.creationEvidence?.();
2156
+ const assignmentProvenanceProven = nativeReplacementAssignmentProvenanceProvesTarget(creationEvidence, target.url, replacementRef);
2157
+ if (!assignmentProvenanceProven) {
2158
+ await closeMacOsBrowserOwnedTab(activeAttachment.metadata.product, replacementRef, timeout).catch(() => undefined);
2159
+ throw new AssistantPluginError(
2160
+ 'PLUGIN_BROWSER_NATIVE_REPLACEMENT_PROVENANCE_UNPROVEN',
2161
+ 'Native replacement tab did not prove exact requested-URL assignment through the stable create-tab provenance contract; preserving the original plugin-owned tab.',
2162
+ {
2163
+ retryable: true,
2164
+ details: {
2165
+ browserProduct: activeAttachment.metadata.product,
2166
+ stableRef: creationEvidence?.refSource === 'structured',
2167
+ provenanceVersion: creationEvidence?.navigation?.provenanceVersion ?? null,
2168
+ assignmentAccepted: creationEvidence?.navigation?.assignmentAccepted === true,
2169
+ requestedUrlMatches: creationEvidence?.navigation
2170
+ ? creationEvidence.navigation.requestedUrl === target.url
2171
+ : false,
2172
+ },
2173
+ },
2174
+ );
2175
+ }
2250
2176
  try {
2177
+ const previousUrl = target.existingSession?.url ?? candidate.url();
2251
2178
  const identity = await settleNativeCreatedPageIdentity(
2252
2179
  replacement as unknown as {
2253
2180
  waitForLoadState: (state?: string, options?: Record<string, unknown>) => Promise<void>;
@@ -2255,12 +2182,13 @@ async function openNativeAttachedContext(
2255
2182
  },
2256
2183
  waitUntil(args.wait_until),
2257
2184
  timeout,
2185
+ { requestedUrl: target.url, previousUrl },
2258
2186
  );
2259
- if (!nativeReplacementUrlMatchesTarget(target.url, identity.url)) {
2187
+ if (!nativeReplacementPostAssignmentUrlMatchesTarget(target.url, identity.url, assignmentProvenanceProven)) {
2260
2188
  const diagnostic = nativeReplacementMismatchDiagnostic({
2261
2189
  requestedUrl: target.url,
2262
2190
  actualUrl: identity.url,
2263
- previousUrl: target.existingSession?.url ?? candidate.url(),
2191
+ previousUrl,
2264
2192
  obsoleteRef,
2265
2193
  replacementRef,
2266
2194
  });
@@ -2544,6 +2472,7 @@ async function openBrowser(
2544
2472
  }
2545
2473
 
2546
2474
  async function withPage<T>(
2475
+ actionId: string,
2547
2476
  repoRoot: string,
2548
2477
  config: BrowserPluginConfig,
2549
2478
  target: BrowserActionTarget,
@@ -2552,20 +2481,27 @@ async function withPage<T>(
2552
2481
  options: { persistSession?: boolean; requireExistingResource?: boolean; pruneStaleSessionMetadata?: boolean } = {},
2553
2482
  ): Promise<T> {
2554
2483
  const retries = Math.min(Math.max(positiveNumber(args.retries, 1), 1), 3);
2484
+ const requireExistingResource = options.requireExistingResource ?? Boolean(target.existingSession);
2485
+ const postDispatchReplaySafe = canReplayBrowserActionAfterDispatch(actionId, requireExistingResource);
2555
2486
  let lastError: unknown;
2556
2487
  for (let attempt = 1; attempt <= retries; attempt += 1) {
2557
2488
  let handle: BrowserOpenHandle | undefined;
2489
+ // Opening a browser resource may itself create or navigate a tab before a
2490
+ // handle is returned. Replay is safe only for an observation action that is
2491
+ // fenced to an already-existing resource. Anything else is potentially
2492
+ // externally mutating from the outset.
2493
+ let actionDispatched = !postDispatchReplaySafe;
2558
2494
  try {
2559
2495
  if (target.existingSession) assertBrowserSessionAvailable(repoRoot, target.sessionId);
2560
- const requireExistingResource = options.requireExistingResource ?? Boolean(target.existingSession);
2561
2496
  const browserArgs = requireExistingResource
2562
2497
  ? { ...args, __forge_require_existing_resource: true }
2563
2498
  : args;
2564
2499
  handle = await openBrowser(repoRoot, config, target, browserArgs);
2500
+ actionDispatched = true;
2565
2501
  const result = await run(handle.page, handle.diagnostics, handle.connection);
2566
2502
  if (options.persistSession) {
2567
2503
  const identity = await readPageIdentity(handle.page, handle.connection, target.url);
2568
- saveSession(repoRoot, sessionFromPage(target, identity.url, identity.title, handle.connection));
2504
+ saveBrowserSession(repoRoot, sessionFromPage(target, identity.url, identity.title, handle.connection));
2569
2505
  if (result && typeof result === 'object' && !Array.isArray(result) && Object.prototype.hasOwnProperty.call(result, 'url')) {
2570
2506
  (result as Record<string, unknown>).url = identity.url;
2571
2507
  }
@@ -2590,11 +2526,21 @@ async function withPage<T>(
2590
2526
  && target.existingSession
2591
2527
  && error instanceof AssistantPluginError
2592
2528
  && error.code === 'PLUGIN_BROWSER_SESSION_STATE_LOST') {
2593
- removeSession(repoRoot, target.sessionId);
2529
+ removeBrowserSession(repoRoot, target.sessionId);
2594
2530
  }
2595
2531
  const message = error instanceof Error ? error.message : String(error);
2596
- const transient = /timeout|net::|ERR_|Navigation failed|Target closed|Protocol error/i.test(message);
2597
- if (!transient || attempt >= retries) throw error;
2532
+ const transient = error instanceof AssistantPluginError
2533
+ ? error.retryable
2534
+ : /timeout|timed out|net::|ERR_|Navigation failed|Target closed|Protocol error/i.test(message);
2535
+ if (!transient) throw error;
2536
+ if (actionDispatched && !postDispatchReplaySafe) {
2537
+ throw new AssistantPluginError(
2538
+ 'PLUGIN_BROWSER_MUTATION_OUTCOME_UNKNOWN',
2539
+ `Browser action ${actionId} reported a transient failure after dispatch. Forge refused automatic replay because the external mutation outcome is unknown; verify current browser state before retrying.`,
2540
+ { retryable: false, details: { actionId, requestedRetries: retries } },
2541
+ );
2542
+ }
2543
+ if (attempt >= retries) throw error;
2598
2544
  } finally {
2599
2545
  if (handle) await handle.close();
2600
2546
  }
@@ -2608,213 +2554,6 @@ function listSavedSessions(repoRoot: string): BrowserSessionState[] {
2608
2554
  .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
2609
2555
  }
2610
2556
 
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;
2785
- try {
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
2557
  async function inspectSavedSessions(
2819
2558
  repoRoot: string,
2820
2559
  config: BrowserPluginConfig,
@@ -2826,7 +2565,7 @@ async function inspectSavedSessions(
2826
2565
  let unverifiedSessionCount = 0;
2827
2566
  let deadManagedSessionCount = 0;
2828
2567
  let prunedSessionCount = 0;
2829
- const native = await inspectNativeOwnedSessions(repoRoot, config, saved, options);
2568
+ const native = await inspectNativeOwnedSessions({ repoRoot, savedSessions: saved, timeoutMs: config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS, pruneDead: options.pruneDead });
2830
2569
  let liveNativeSessionCount = native.liveCount;
2831
2570
  let deadNativeSessionCount = native.deadCount;
2832
2571
  let closedInvalidNativeSessionCount = native.closedInvalidCount;
@@ -2875,7 +2614,7 @@ async function inspectSavedSessions(
2875
2614
  const pruned = options.pruneDead === true;
2876
2615
  if (pruned) {
2877
2616
  state.pagesBySession.delete(session.sessionId);
2878
- removeSession(repoRoot, session.sessionId);
2617
+ removeBrowserSession(repoRoot, session.sessionId);
2879
2618
  prunedSessionCount += 1;
2880
2619
  }
2881
2620
  sessions.push({ session, liveness: 'dead', evidence: 'runtime_bound_page_missing', pruned });
@@ -3328,7 +3067,7 @@ async function finalizeInteractiveAction(
3328
3067
  const identity = await readPageIdentity(page, connection, target.url);
3329
3068
  const title = identity.title;
3330
3069
  const pageUrl = identity.url;
3331
- const session = saveSession(repoRoot, sessionFromPage(target, pageUrl, title, connection));
3070
+ const session = saveBrowserSession(repoRoot, sessionFromPage(target, pageUrl, title, connection));
3332
3071
  let screenshot: BrowserActionScreenshot | undefined;
3333
3072
  const silentNativeEvidence = connection.provider === 'macos-apple-events';
3334
3073
  if (!silentNativeEvidence) {
@@ -3350,598 +3089,6 @@ async function finalizeInteractiveAction(
3350
3089
  };
3351
3090
  }
3352
3091
 
3353
- function permissions(ready: boolean): AssistantPluginPermissionScope[] {
3354
- return [
3355
- {
3356
- scope: 'browser.read',
3357
- mode: 'read',
3358
- description: 'Open pages, extract text, and capture screenshots.',
3359
- granted: ready,
3360
- required: true,
3361
- },
3362
- {
3363
- scope: 'browser.interact',
3364
- mode: 'write',
3365
- description: 'Click, type, press keys, and wait on allowed browser pages after explicit authorization.',
3366
- granted: ready,
3367
- required: true,
3368
- },
3369
- {
3370
- scope: 'browser.profile',
3371
- mode: 'write',
3372
- description: 'Persist the dedicated local browser profile, session metadata, and screenshots.',
3373
- granted: ready,
3374
- required: true,
3375
- },
3376
- ];
3377
- }
3378
-
3379
- function capabilities(): AssistantPluginCapability[] {
3380
- return [
3381
- {
3382
- capabilityId: 'browser-session',
3383
- title: 'Browser Sessions',
3384
- description: 'Create, list, reuse, and close persistent browser sessions.',
3385
- scopes: ['browser.read', 'browser.profile'],
3386
- actions: ['create_session', 'list_sessions', 'reconcile_sessions', 'close_session', 'close_page', 'clear_session'],
3387
- },
3388
- {
3389
- capabilityId: 'browser-human-handoff',
3390
- title: 'Browser Human Handoff',
3391
- description: 'Present the persistent browser in the foreground, keep it open for manual verification, and resume safely.',
3392
- scopes: ['browser.read', 'browser.profile'],
3393
- actions: ['request_human_handoff', 'get_handoff_status', 'resolve_handoff'],
3394
- },
3395
- {
3396
- capabilityId: 'browser-readonly',
3397
- title: 'Read-only Browser',
3398
- description: 'Navigate allowed pages, extract DOM/text, capture screenshots, and collect diagnostics.',
3399
- scopes: ['browser.read', 'browser.profile'],
3400
- actions: [
3401
- 'open_page', 'navigate', 'reload', 'go_back', 'wait_for_load_state',
3402
- 'get_text', 'get_html', 'query_selector', 'query_all', 'get_attribute', 'list_frames', 'verify_state',
3403
- 'screenshot', 'extract_links', 'extract_tables', 'extract_forms', 'snapshot_interactive',
3404
- 'get_console_errors', 'get_failed_requests',
3405
- ],
3406
- },
3407
- {
3408
- capabilityId: 'browser-interaction',
3409
- title: 'Browser Interaction',
3410
- description: 'Perform explicit form and pointer interactions on HTTP(S) pages through the persistent Playwright profile.',
3411
- scopes: ['browser.interact', 'browser.profile'],
3412
- actions: [
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',
3415
- ],
3416
- },
3417
- ];
3418
- }
3419
-
3420
- function sessionTargetSchema(extra: Record<string, unknown> = {}, required: string[] = []): Record<string, unknown> {
3421
- return {
3422
- type: 'object',
3423
- properties: {
3424
- session_id: { type: 'string' },
3425
- url: { type: 'string' },
3426
- wait_until: { type: 'string', enum: ['load', 'domcontentloaded', 'networkidle'] },
3427
- timeout_ms: { type: 'number' },
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 },
3433
- ...extra,
3434
- },
3435
- ...(required.length > 0 ? { required } : {}),
3436
- additionalProperties: false,
3437
- };
3438
- }
3439
-
3440
- const frameScopeProperties = {
3441
- frame_url: { type: 'string' },
3442
- frame_name: { type: 'string' },
3443
- };
3444
-
3445
- function interactSchema(extra: Record<string, unknown>, required: string[]): Record<string, unknown> {
3446
- return sessionTargetSchema({
3447
- post_action_wait_ms: { type: 'number' },
3448
- ...extra,
3449
- }, required);
3450
- }
3451
-
3452
- function actions(): AssistantPluginActionDescriptor[] {
3453
- const readRemote = [
3454
- { resource: 'remote' as const, mode: 'read' as const },
3455
- { resource: 'repo-state' as const, mode: 'write' as const },
3456
- ];
3457
- const writeRemote = [
3458
- { resource: 'remote' as const, mode: 'exclusive' as const },
3459
- { resource: 'repo-state' as const, mode: 'write' as const },
3460
- ];
3461
- const descriptors: AssistantPluginActionDescriptor[] = [
3462
- {
3463
- actionId: 'configure',
3464
- title: 'Configure browser plugin',
3465
- description: 'Enable or update the local browser plugin configuration.',
3466
- readOnly: false,
3467
- risk: 'workspace_write',
3468
- confirmation: 'authorization',
3469
- defaultTimeoutMs: 30_000,
3470
- cancellable: true,
3471
- idempotent: true,
3472
- scopes: ['browser.profile'],
3473
- resourceClaims: [{ resource: 'repo-state', mode: 'write' }],
3474
- argumentsSchema: {
3475
- type: 'object',
3476
- properties: {
3477
- enabled: { type: 'boolean' },
3478
- browser_mode: { type: 'string', enum: ['attach_preferred', 'managed_persistent', 'isolated'] },
3479
- profile_mode: { type: 'string', enum: ['repo_local', 'custom'] },
3480
- profile_dir: { type: 'string' },
3481
- profile_directory: { type: 'string' },
3482
- clear_profile_dir: { type: 'boolean' },
3483
- clear_profile_directory: { type: 'boolean' },
3484
- browser_channel: { type: 'string', enum: ['chromium', 'chrome', 'chrome-beta', 'chrome-dev', 'chrome-canary'] },
3485
- clear_browser_channel: { type: 'boolean' },
3486
- browser_executable_path: { type: 'string' },
3487
- clear_browser_executable_path: { type: 'boolean' },
3488
- cdp_endpoint: { type: 'string' },
3489
- clear_cdp_endpoint: { type: 'boolean' },
3490
- cdp_endpoint_candidates: { type: 'array', items: { type: 'string' }, maxItems: MAX_CDP_ENDPOINT_CANDIDATES },
3491
- clear_cdp_endpoint_candidates: { type: 'boolean' },
3492
- cdp_discovery_timeout_ms: { type: 'number' },
3493
- cdp_attach_fallback: { type: 'string', enum: ['managed_persistent', 'fail_closed'] },
3494
- native_attach_mode: { type: 'string', enum: ['auto', 'disabled'] },
3495
- native_browser_candidates: { type: 'array', items: { type: 'string', enum: ['vivaldi', 'chrome'] }, maxItems: 2 },
3496
- default_timeout_ms: { type: 'number' },
3497
- },
3498
- additionalProperties: false,
3499
- },
3500
- },
3501
- {
3502
- actionId: 'create_session',
3503
- title: 'Create browser session',
3504
- description: 'Open an HTTP(S) URL or explicitly adopt the matching frontmost native tab, then persist a reusable session id.',
3505
- readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
3506
- scopes: ['browser.read', 'browser.profile'], resourceClaims: [{ resource: 'repo-state', mode: 'write' }, ...readRemote],
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']),
3515
- },
3516
- {
3517
- actionId: 'list_sessions',
3518
- title: 'List browser sessions',
3519
- description: 'List saved browser session metadata without secrets or cookies.',
3520
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 10_000, cancellable: true, idempotent: true,
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' }],
3537
- argumentsSchema: { type: 'object', properties: {}, additionalProperties: false },
3538
- },
3539
- {
3540
- actionId: 'close_session',
3541
- title: 'Close browser session',
3542
- description: 'Remove one saved session metadata record.',
3543
- readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 15_000, cancellable: true, idempotent: true,
3544
- scopes: ['browser.read', 'browser.profile'], resourceClaims: [{ resource: 'repo-state', mode: 'write' }],
3545
- argumentsSchema: { type: 'object', properties: { session_id: { type: 'string' } }, required: ['session_id'], additionalProperties: false },
3546
- },
3547
- {
3548
- actionId: 'clear_session',
3549
- title: 'Clear all browser sessions',
3550
- description: 'Remove all saved session metadata while keeping the profile.',
3551
- readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 15_000, cancellable: true, idempotent: true,
3552
- scopes: ['browser.profile'], resourceClaims: [{ resource: 'repo-state', mode: 'write' }],
3553
- argumentsSchema: { type: 'object', properties: {}, additionalProperties: false },
3554
- },
3555
- {
3556
- actionId: 'request_human_handoff',
3557
- title: 'Request human browser handoff',
3558
- description: 'Keep a saved browser session open for CAPTCHA, login, two-factor authentication, or another manual step.',
3559
- readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 30_000, cancellable: true, idempotent: false,
3560
- scopes: ['browser.read', 'browser.profile'], resourceClaims: readRemote,
3561
- argumentsSchema: sessionTargetSchema({
3562
- reason: { type: 'string', enum: ['captcha', 'login', 'two_factor', 'manual_review', 'sensitive_confirmation'] },
3563
- instructions: { type: 'string' },
3564
- handoff_timeout_ms: { type: 'number' },
3565
- }, ['session_id']),
3566
- },
3567
- {
3568
- actionId: 'get_handoff_status',
3569
- title: 'Get browser handoff status',
3570
- description: 'Read durable browser handoff status and reconcile a stale or crashed host.',
3571
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 10_000, cancellable: true, idempotent: true,
3572
- scopes: ['browser.read', 'browser.profile'], resourceClaims: [{ resource: 'repo-state', mode: 'read' }],
3573
- argumentsSchema: { type: 'object', properties: { interaction_id: { type: 'string' } }, required: ['interaction_id'], additionalProperties: false },
3574
- },
3575
- {
3576
- actionId: 'resolve_handoff',
3577
- title: 'Resolve browser handoff',
3578
- description: 'Resume or cancel a foreground browser handoff.',
3579
- readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 15_000, cancellable: true, idempotent: true,
3580
- scopes: ['browser.read', 'browser.profile'], resourceClaims: [{ resource: 'repo-state', mode: 'write' }],
3581
- argumentsSchema: {
3582
- type: 'object',
3583
- properties: {
3584
- interaction_id: { type: 'string' },
3585
- resolution: { type: 'string', enum: ['resume', 'cancel'] },
3586
- },
3587
- required: ['interaction_id', 'resolution'],
3588
- additionalProperties: false,
3589
- },
3590
- },
3591
- {
3592
- actionId: 'open_page',
3593
- title: 'Open page',
3594
- description: 'Open an HTTP(S) URL with the persistent profile and save a lightweight session.',
3595
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
3596
- scopes: ['browser.read'], resourceClaims: readRemote,
3597
- argumentsSchema: sessionTargetSchema({ extract_text: { type: 'boolean' }, max_chars: { type: 'number' } }, ['url']),
3598
- },
3599
- {
3600
- actionId: 'navigate',
3601
- title: 'Navigate',
3602
- description: 'Navigate an existing session or open a new page to an HTTP(S) URL.',
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,
3605
- argumentsSchema: sessionTargetSchema({}, ['url']),
3606
- },
3607
- {
3608
- actionId: 'reload',
3609
- title: 'Reload page',
3610
- description: 'Reload the current page for a session.',
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,
3613
- argumentsSchema: sessionTargetSchema({}, ['session_id']),
3614
- },
3615
- {
3616
- actionId: 'go_back',
3617
- title: 'Go back',
3618
- description: 'Navigate back in history for a session.',
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,
3621
- argumentsSchema: sessionTargetSchema({}, ['session_id']),
3622
- },
3623
- {
3624
- actionId: 'wait_for_load_state',
3625
- title: 'Wait for load state',
3626
- description: 'Wait for load/domcontentloaded/networkidle on a session page.',
3627
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
3628
- scopes: ['browser.read'], resourceClaims: readRemote,
3629
- argumentsSchema: sessionTargetSchema({ state: { type: 'string', enum: ['load', 'domcontentloaded', 'networkidle'] } }),
3630
- },
3631
- {
3632
- actionId: 'get_text',
3633
- title: 'Get text',
3634
- description: 'Extract text from a URL or saved browser session.',
3635
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
3636
- scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
3637
- argumentsSchema: sessionTargetSchema({ selector: { type: 'string' }, max_chars: { type: 'number' }, ...frameScopeProperties }),
3638
- },
3639
- {
3640
- actionId: 'get_html',
3641
- title: 'Get HTML',
3642
- description: 'Extract HTML for the page or a selector (bounded).',
3643
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
3644
- scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
3645
- argumentsSchema: sessionTargetSchema({ selector: { type: 'string' }, max_chars: { type: 'number' }, ...frameScopeProperties }),
3646
- },
3647
- {
3648
- actionId: 'query_selector',
3649
- title: 'Query selector',
3650
- description: 'Return the first matching element summary with a stable selector hint.',
3651
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
3652
- scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
3653
- argumentsSchema: sessionTargetSchema({ selector: { type: 'string' }, ...frameScopeProperties }, ['selector']),
3654
- },
3655
- {
3656
- actionId: 'query_all',
3657
- title: 'Query all selectors',
3658
- description: 'Return matching element summaries (bounded).',
3659
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
3660
- scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
3661
- argumentsSchema: sessionTargetSchema({ selector: { type: 'string' }, limit: { type: 'number' }, ...frameScopeProperties }, ['selector']),
3662
- },
3663
- {
3664
- actionId: 'get_attribute',
3665
- title: 'Get attribute',
3666
- description: 'Read one attribute from a selector.',
3667
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
3668
- scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
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']),
3694
- },
3695
- {
3696
- actionId: 'screenshot',
3697
- title: 'Screenshot',
3698
- description: 'Capture a page, full-page, or element screenshot under browser artifact storage.',
3699
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
3700
- scopes: ['browser.read'], resourceClaims: readRemote,
3701
- argumentsSchema: sessionTargetSchema({ full_page: { type: 'boolean' }, selector: { type: 'string' } }),
3702
- },
3703
- {
3704
- actionId: 'extract_links',
3705
- title: 'Extract links',
3706
- description: 'Extract anchors with href/text from the page.',
3707
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
3708
- scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
3709
- argumentsSchema: sessionTargetSchema({ limit: { type: 'number' } }),
3710
- },
3711
- {
3712
- actionId: 'extract_tables',
3713
- title: 'Extract tables',
3714
- description: 'Extract simple HTML tables as row arrays.',
3715
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
3716
- scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
3717
- argumentsSchema: sessionTargetSchema({ limit: { type: 'number' } }),
3718
- },
3719
- {
3720
- actionId: 'extract_forms',
3721
- title: 'Extract forms',
3722
- description: 'Extract form field summaries without values that look like secrets.',
3723
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
3724
- scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
3725
- argumentsSchema: sessionTargetSchema({ limit: { type: 'number' } }),
3726
- },
3727
- {
3728
- actionId: 'snapshot_interactive',
3729
- title: 'Snapshot interactive elements',
3730
- description: 'Snapshot interactive elements with stable selector hints plus CSS-pixel viewport geometry for screenshot-to-input grounding.',
3731
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
3732
- scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
3733
- argumentsSchema: sessionTargetSchema({ limit: { type: 'number' }, ...frameScopeProperties }),
3734
- },
3735
- {
3736
- actionId: 'get_console_errors',
3737
- title: 'Get console errors',
3738
- description: 'Return captured console error messages for a page open cycle.',
3739
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
3740
- scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
3741
- argumentsSchema: sessionTargetSchema({}),
3742
- },
3743
- {
3744
- actionId: 'get_failed_requests',
3745
- title: 'Get failed requests',
3746
- description: 'Return failed network requests captured during a page open cycle.',
3747
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
3748
- scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
3749
- argumentsSchema: sessionTargetSchema({}),
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
- },
3759
- {
3760
- actionId: 'click',
3761
- title: 'Click element',
3762
- description: 'Click a selector on an allowed page after authorization.',
3763
- readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
3764
- scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
3765
- argumentsSchema: interactSchema({ selector: { type: 'string' } }, ['selector']),
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
- },
3775
- {
3776
- actionId: 'double_click',
3777
- title: 'Double-click element',
3778
- description: 'Double-click a selector after authorization.',
3779
- readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
3780
- scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
3781
- argumentsSchema: interactSchema({ selector: { type: 'string' } }, ['selector']),
3782
- },
3783
- {
3784
- actionId: 'hover',
3785
- title: 'Hover element',
3786
- description: 'Hover a selector after authorization.',
3787
- readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
3788
- scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
3789
- argumentsSchema: interactSchema({ selector: { type: 'string' } }, ['selector']),
3790
- },
3791
- {
3792
- actionId: 'focus',
3793
- title: 'Focus element',
3794
- description: 'Focus a selector after authorization.',
3795
- readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
3796
- scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
3797
- argumentsSchema: interactSchema({ selector: { type: 'string' } }, ['selector']),
3798
- },
3799
- {
3800
- actionId: 'type',
3801
- title: 'Type into element',
3802
- description: 'Type text into a selector (append-style) after authorization.',
3803
- readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
3804
- scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
3805
- argumentsSchema: interactSchema({ selector: { type: 'string' }, text: { type: 'string' } }, ['selector', 'text']),
3806
- },
3807
- {
3808
- actionId: 'fill',
3809
- title: 'Fill element',
3810
- description: 'Replace the value of a selector after authorization.',
3811
- readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
3812
- scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
3813
- argumentsSchema: interactSchema({ selector: { type: 'string' }, text: { type: 'string' } }, ['selector', 'text']),
3814
- },
3815
- {
3816
- actionId: 'select_option',
3817
- title: 'Select option',
3818
- description: 'Select one or more option values on a <select> after authorization.',
3819
- readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
3820
- scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
3821
- argumentsSchema: interactSchema({ selector: { type: 'string' }, values: { type: 'array', items: { type: 'string' } } }, ['selector', 'values']),
3822
- },
3823
- {
3824
- actionId: 'check',
3825
- title: 'Check checkbox',
3826
- description: 'Check a checkbox/radio after authorization.',
3827
- readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
3828
- scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
3829
- argumentsSchema: interactSchema({ selector: { type: 'string' } }, ['selector']),
3830
- },
3831
- {
3832
- actionId: 'uncheck',
3833
- title: 'Uncheck checkbox',
3834
- description: 'Uncheck a checkbox after authorization.',
3835
- readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
3836
- scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
3837
- argumentsSchema: interactSchema({ selector: { type: 'string' } }, ['selector']),
3838
- },
3839
- {
3840
- actionId: 'press',
3841
- title: 'Press key',
3842
- description: 'Press a key on a selector on an allowed page after authorization.',
3843
- readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
3844
- scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
3845
- argumentsSchema: interactSchema({ selector: { type: 'string' }, key: { type: 'string' } }, ['selector', 'key']),
3846
- },
3847
- {
3848
- actionId: 'keyboard_shortcut',
3849
- title: 'Keyboard shortcut',
3850
- description: 'Press a keyboard shortcut (e.g. Meta+A) after authorization.',
3851
- readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
3852
- scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
3853
- argumentsSchema: interactSchema({ key: { type: 'string' } }, ['key']),
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
- },
3888
- {
3889
- actionId: 'wait_for_selector',
3890
- title: 'Wait for selector',
3891
- description: 'Wait for a selector state on an allowed page after authorization.',
3892
- readOnly: true, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
3893
- scopes: ['browser.interact', 'browser.profile'], resourceClaims: readRemote,
3894
- argumentsSchema: sessionTargetSchema({
3895
- selector: { type: 'string' },
3896
- state: { type: 'string', enum: ['attached', 'detached', 'visible', 'hidden'] },
3897
- }, ['selector']),
3898
- },
3899
- {
3900
- actionId: 'attach_local_file',
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.',
3903
- readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
3904
- scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
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']),
3910
- },
3911
- {
3912
- actionId: 'await_file_transfer',
3913
- title: 'Await file transfer',
3914
- description: 'Capture a browser download into bounded artifact storage after authorization. Never auto-opens downloaded files.',
3915
- readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 120_000, cancellable: true, idempotent: false,
3916
- scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
3917
- argumentsSchema: interactSchema({ selector: { type: 'string' }, suggested_name: { type: 'string' } }, ['selector']),
3918
- },
3919
- {
3920
- actionId: 'close_page',
3921
- title: 'Close session',
3922
- description: 'Remove saved session metadata while keeping the persistent profile.',
3923
- readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 15_000, cancellable: true, idempotent: true,
3924
- scopes: ['browser.read', 'browser.profile'], resourceClaims: [{ resource: 'repo-state', mode: 'write' }],
3925
- argumentsSchema: { type: 'object', properties: { session_id: { type: 'string' } }, required: ['session_id'], additionalProperties: false },
3926
- },
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
- }));
3943
- }
3944
-
3945
3092
  function browserUserFacingStatus(config: BrowserPluginConfig, ready: boolean, sessionCount = 0): string {
3946
3093
  if (!config.enabled) return 'disabled';
3947
3094
  if (!ready) return 'not ready';
@@ -4050,7 +3197,9 @@ function health(config: BrowserPluginConfig, repoRoot?: string): AssistantPlugin
4050
3197
  details: {
4051
3198
  ...baseDetails,
4052
3199
  provider: config.browserMode === 'attach_preferred'
4053
- ? 'cdp-or-macos-active-browser-or-persistent-context'
3200
+ ? config.cdpAttachFallback === 'managed_persistent'
3201
+ ? 'cdp-or-macos-active-browser-or-persistent-context'
3202
+ : 'cdp-or-macos-active-browser'
4054
3203
  : 'playwright-persistent-context',
4055
3204
  userFacingStatus: 'not ready',
4056
3205
  },
@@ -4097,15 +3246,19 @@ export function buildBrowserPluginManifest(previousRevision = 0, previousUpdated
4097
3246
  reason: !config.enabled
4098
3247
  ? 'Browser plugin is disabled.'
4099
3248
  : state.ready
4100
- ? `Browser plugin is ready via ${config.browserMode === 'attach_preferred' ? 'CDP, macOS active-browser attach, and explicit managed fallback.' : 'Playwright persistent context.'}`
3249
+ ? `Browser plugin is ready via ${config.browserMode === 'attach_preferred'
3250
+ ? config.cdpAttachFallback === 'managed_persistent'
3251
+ ? 'CDP, macOS native attach, and explicit managed fallback.'
3252
+ : 'CDP or macOS native attach; managed fallback is disabled.'
3253
+ : 'Playwright persistent context.'}`
4101
3254
  : state.state === 'degraded'
4102
3255
  ? state.warnings[0] ?? 'Browser plugin requires a successful live native attach probe.'
4103
3256
  : state.errors[0],
4104
3257
  },
4105
3258
  health: state,
4106
- permissions: permissions(state.ready),
4107
- capabilities: capabilities(),
4108
- actions: actions(),
3259
+ permissions: browserPermissions(state.ready),
3260
+ capabilities: browserCapabilities(),
3261
+ actions: browserActions(),
4109
3262
  updatedAt: previousUpdatedAt ?? now(),
4110
3263
  };
4111
3264
  }
@@ -4127,7 +3280,7 @@ async function executeBrowserPluginActionInternal(
4127
3280
  ): Promise<Record<string, unknown>> {
4128
3281
  const persisted = loadConfig(input.repoRoot);
4129
3282
  const actionSessionId = input.actionId === 'configure' ? undefined : stringValue(input.args.session_id);
4130
- const actionSession = actionSessionId ? findSession(input.repoRoot, actionSessionId) : undefined;
3283
+ const actionSession = actionSessionId ? findBrowserSession(input.repoRoot, actionSessionId) : undefined;
4131
3284
  const current = input.actionId === 'configure' ? persisted : effectiveBrowserActionConfig(persisted, input.args, actionSession);
4132
3285
  if (!current.enabled && input.actionId !== 'configure') {
4133
3286
  throw new AssistantPluginError('PLUGIN_DISABLED', 'Browser plugin is disabled.', { retryable: false });
@@ -4299,12 +3452,12 @@ async function executeBrowserPluginActionInternal(
4299
3452
  case 'close_page': {
4300
3453
  const sessionId = requiredString(input.args.session_id, 'session_id');
4301
3454
  assertBrowserSessionAvailable(input.repoRoot, sessionId);
4302
- const session = findSession(input.repoRoot, sessionId);
3455
+ const session = findBrowserSession(input.repoRoot, sessionId);
4303
3456
  const tab = session?.browser?.tab;
4304
3457
  const managedClosed = session ? await closeManagedSessionPage(input.repoRoot, current, session) : false;
4305
- const nativeClose = session ? await closeTrackedNativeOwnedSession(session, current) : { resourceClosed: false, resourceAlreadyMissing: false };
3458
+ const nativeClose = session ? await closeTrackedNativeOwnedSession(session, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS) : { resourceClosed: false, resourceAlreadyMissing: false };
4306
3459
  const closedSessionIds = session ? nativeOwnedAliasSessionIds(input.repoRoot, session) : [sessionId];
4307
- for (const closedSessionId of closedSessionIds) removeSession(input.repoRoot, closedSessionId);
3460
+ for (const closedSessionId of closedSessionIds) removeBrowserSession(input.repoRoot, closedSessionId);
4308
3461
  return {
4309
3462
  closed: true, sessionId,
4310
3463
  resourceClosed: managedClosed || nativeClose.resourceClosed,
@@ -4324,10 +3477,10 @@ async function executeBrowserPluginActionInternal(
4324
3477
  let resourceAlreadyMissingCount = 0;
4325
3478
  for (const session of sessions) {
4326
3479
  try {
4327
- const nativeClose = await closeTrackedNativeOwnedSession(session, current);
3480
+ const nativeClose = await closeTrackedNativeOwnedSession(session, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS);
4328
3481
  if (nativeClose.resourceClosed) resourceClosedCount += 1;
4329
3482
  if (nativeClose.resourceAlreadyMissing) resourceAlreadyMissingCount += 1;
4330
- removeSession(input.repoRoot, session.sessionId);
3483
+ removeBrowserSession(input.repoRoot, session.sessionId);
4331
3484
  removedCount += 1;
4332
3485
  } catch (error) {
4333
3486
  failedSessionIds.push(session.sessionId);
@@ -4356,7 +3509,6 @@ async function executeBrowserPluginActionInternal(
4356
3509
  requestId: input.requestId,
4357
3510
  jobId: input.jobId,
4358
3511
  sessionId: target.sessionId,
4359
- sessionPath: sessionPath(input.repoRoot, target.sessionId),
4360
3512
  url: target.url,
4361
3513
  profileDir: managedTarget.profile.profileDir,
4362
3514
  selectedProfilePath: managedTarget.profile.selectedProfilePath,
@@ -4405,7 +3557,6 @@ async function executeBrowserPluginActionInternal(
4405
3557
  requestId: input.requestId,
4406
3558
  jobId: input.jobId,
4407
3559
  sessionId: target.sessionId,
4408
- sessionPath: sessionPath(input.repoRoot, target.sessionId),
4409
3560
  url: target.url,
4410
3561
  profileDir: profile.profileDir,
4411
3562
  selectedProfilePath,
@@ -4428,6 +3579,17 @@ async function executeBrowserPluginActionInternal(
4428
3579
  case 'get_handoff_status': {
4429
3580
  const interactionId = requiredString(input.args.interaction_id, 'interaction_id');
4430
3581
  const handoff = getBrowserHandoff(input.repoRoot, interactionId);
3582
+ if (handoff.status === 'completed' && handoff.result?.url) {
3583
+ const session = findBrowserSession(input.repoRoot, handoff.sessionId);
3584
+ if (session) {
3585
+ saveBrowserSession(input.repoRoot, {
3586
+ ...session,
3587
+ url: handoff.result.url,
3588
+ title: handoff.result.title,
3589
+ updatedAt: new Date().toISOString(),
3590
+ });
3591
+ }
3592
+ }
4431
3593
  return {
4432
3594
  provider: isRuntimeManagedBrowserHandoff(handoff)
4433
3595
  ? 'playwright-runtime-managed-handoff'
@@ -4445,13 +3607,13 @@ async function executeBrowserPluginActionInternal(
4445
3607
  }
4446
3608
  const currentHandoff = getBrowserHandoff(input.repoRoot, interactionId);
4447
3609
  if (isRuntimeManagedBrowserHandoff(currentHandoff)) {
4448
- const session = findSession(input.repoRoot, currentHandoff.sessionId);
3610
+ const session = findBrowserSession(input.repoRoot, currentHandoff.sessionId);
4449
3611
  const managedTarget = session ? await runtimeManagedPageForSession(input.repoRoot, current, session) : undefined;
4450
3612
  const result = managedTarget
4451
3613
  ? { url: managedTarget.page.url(), title: await managedTarget.page.title().catch(() => session?.title) }
4452
3614
  : currentHandoff.result;
4453
3615
  if (resolution === 'resume' && session && result?.url) {
4454
- saveSession(input.repoRoot, {
3616
+ saveBrowserSession(input.repoRoot, {
4455
3617
  ...session,
4456
3618
  url: result.url,
4457
3619
  title: result.title,
@@ -4466,8 +3628,19 @@ async function executeBrowserPluginActionInternal(
4466
3628
  };
4467
3629
  }
4468
3630
  const handoff = resolution === 'resume'
4469
- ? resumeBrowserHandoff(input.repoRoot, interactionId, input.requestId)
3631
+ ? await resumeBrowserHandoffAndWait(input.repoRoot, interactionId, input.requestId)
4470
3632
  : cancelBrowserHandoff(input.repoRoot, interactionId, input.requestId);
3633
+ if (resolution === 'resume' && handoff.status === 'completed' && handoff.result?.url) {
3634
+ const session = findBrowserSession(input.repoRoot, handoff.sessionId);
3635
+ if (session) {
3636
+ saveBrowserSession(input.repoRoot, {
3637
+ ...session,
3638
+ url: handoff.result.url,
3639
+ title: handoff.result.title,
3640
+ updatedAt: new Date().toISOString(),
3641
+ });
3642
+ }
3643
+ }
4471
3644
  return {
4472
3645
  provider: handoff.targetId.startsWith('macos-apple-events:') ? 'macos-apple-events-handoff-host' : 'playwright-handoff-host',
4473
3646
  resolutionRequested: resolution,
@@ -4488,7 +3661,7 @@ async function executeBrowserPluginActionInternal(
4488
3661
  : normalizedUrl(input.args.url);
4489
3662
  const existingSessionId = stringValue(input.args.session_id);
4490
3663
  const target: BrowserActionTarget = existingSessionId
4491
- ? { sessionId: existingSessionId, url, existingSession: findSession(input.repoRoot, existingSessionId) }
3664
+ ? { sessionId: existingSessionId, url, existingSession: findBrowserSession(input.repoRoot, existingSessionId) }
4492
3665
  : { sessionId: sessionIdFor(url), url };
4493
3666
  if (hasNativeAdoption) {
4494
3667
  if (input.actionId !== 'create_session' || (nativeProduct && !normalizedNativeProduct)) {
@@ -4510,7 +3683,7 @@ async function executeBrowserPluginActionInternal(
4510
3683
  { retryable: false },
4511
3684
  );
4512
3685
  }
4513
- const configuredCandidates = (current.nativeBrowserCandidates ?? ['vivaldi', 'chrome'])
3686
+ const configuredCandidates = (current.nativeBrowserCandidates ?? [...DEFAULT_USER_NATIVE_BROWSER_CANDIDATES])
4514
3687
  .filter((candidate): candidate is MacOsBrowserProduct => candidate === 'chrome' || candidate === 'vivaldi');
4515
3688
  const candidates: MacOsBrowserProduct[] = normalizedNativeProduct ? [normalizedNativeProduct] : configuredCandidates;
4516
3689
  const discovered = await discoverMacOsBrowserAttachment(candidates, Math.min(timeout, MAX_CDP_DISCOVERY_TIMEOUT_MS));
@@ -4607,7 +3780,7 @@ async function executeBrowserPluginActionInternal(
4607
3780
  : 'Explicitly adopted an existing user-owned native browser tab.',
4608
3781
  },
4609
3782
  }, requestedUrl, title, 'saved_url', { ownership: 'user_owned', windowId: adoptedRef.windowId, tabId: adoptedRef.tabId });
4610
- const session = saveSession(input.repoRoot, sessionFromPage(target, requestedUrl, title, connection));
3783
+ const session = saveBrowserSession(input.repoRoot, sessionFromPage(target, requestedUrl, title, connection));
4611
3784
  return {
4612
3785
  provider: 'macos-apple-events',
4613
3786
  session,
@@ -4618,9 +3791,9 @@ async function executeBrowserPluginActionInternal(
4618
3791
  : {}),
4619
3792
  };
4620
3793
  }
4621
- return await withPage(input.repoRoot, current, target, input.args, async (page, diagnostics, connection) => {
3794
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, diagnostics, connection) => {
4622
3795
  const identity = await readPageIdentity(page, connection, target.url);
4623
- const session = saveSession(input.repoRoot, sessionFromPage(target, identity.url, identity.title, connection));
3796
+ const session = saveBrowserSession(input.repoRoot, sessionFromPage(target, identity.url, identity.title, connection));
4624
3797
  return {
4625
3798
  provider: browserResultProvider(connection.provider),
4626
3799
  session,
@@ -4640,7 +3813,7 @@ async function executeBrowserPluginActionInternal(
4640
3813
  case 'go_back':
4641
3814
  case 'wait_for_load_state': {
4642
3815
  const target = resolveActionTarget(input.repoRoot, input.args);
4643
- return await withPage(input.repoRoot, current, target, input.args, async (page, diagnostics, connection) => {
3816
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, diagnostics, connection) => {
4644
3817
  if (input.actionId === 'reload') {
4645
3818
  if (page.reload) await page.reload({ waitUntil: waitUntil(input.args.wait_until), timeout: positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS) });
4646
3819
  else await page.goto(target.url, { waitUntil: waitUntil(input.args.wait_until), timeout: positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS) });
@@ -4652,7 +3825,7 @@ async function executeBrowserPluginActionInternal(
4652
3825
  });
4653
3826
  }
4654
3827
  const identity = await readPageIdentity(page, connection);
4655
- const session = saveSession(input.repoRoot, sessionFromPage(target, identity.url, identity.title, connection));
3828
+ const session = saveBrowserSession(input.repoRoot, sessionFromPage(target, identity.url, identity.title, connection));
4656
3829
  return {
4657
3830
  provider: browserResultProvider(connection.provider),
4658
3831
  session,
@@ -4670,7 +3843,7 @@ async function executeBrowserPluginActionInternal(
4670
3843
  }
4671
3844
  case 'get_text': {
4672
3845
  const target = resolveActionTarget(input.repoRoot, input.args);
4673
- return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
3846
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4674
3847
  const selection = resolveBrowserEvaluationScope(page, input.args, connection);
4675
3848
  return {
4676
3849
  provider: browserResultProvider(connection.provider),
@@ -4684,7 +3857,7 @@ async function executeBrowserPluginActionInternal(
4684
3857
  }
4685
3858
  case 'get_html': {
4686
3859
  const target = resolveActionTarget(input.repoRoot, input.args);
4687
- return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
3860
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4688
3861
  const selection = resolveBrowserEvaluationScope(page, input.args, connection);
4689
3862
  const raw = await selection.scope.evaluate<string>(EXTRACTION_SCRIPTS.html(stringValue(input.args.selector)));
4690
3863
  return {
@@ -4702,7 +3875,7 @@ async function executeBrowserPluginActionInternal(
4702
3875
  const target = resolveActionTarget(input.repoRoot, input.args);
4703
3876
  const selector = requiredString(input.args.selector, 'selector');
4704
3877
  const limit = Math.min(positiveNumber(input.args.limit, 25), 100);
4705
- return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
3878
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4706
3879
  const selection = resolveBrowserEvaluationScope(page, input.args, connection);
4707
3880
  const matches = await selection.scope.evaluate<Array<Record<string, unknown>>>(EXTRACTION_SCRIPTS.query(selector, input.actionId === 'query_selector' ? 1 : limit));
4708
3881
  return {
@@ -4722,7 +3895,7 @@ async function executeBrowserPluginActionInternal(
4722
3895
  const target = resolveActionTarget(input.repoRoot, input.args);
4723
3896
  const selector = requiredString(input.args.selector, 'selector');
4724
3897
  const attribute = requiredString(input.args.attribute, 'attribute');
4725
- return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
3898
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4726
3899
  const selection = resolveBrowserEvaluationScope(page, input.args, connection);
4727
3900
  return {
4728
3901
  provider: browserResultProvider(connection.provider),
@@ -4739,7 +3912,7 @@ async function executeBrowserPluginActionInternal(
4739
3912
  case 'list_frames': {
4740
3913
  const target = resolveActionTarget(input.repoRoot, input.args);
4741
3914
  const limit = Math.min(positiveNumber(input.args.limit, 50), 100);
4742
- return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
3915
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4743
3916
  if (connection.provider === 'macos-apple-events' || !page.frames) {
4744
3917
  throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_SCOPE_UNAVAILABLE', 'Frame discovery requires a Playwright/CDP-controlled page; native Apple Events sessions fail closed.', {
4745
3918
  retryable: false,
@@ -4774,7 +3947,7 @@ async function executeBrowserPluginActionInternal(
4774
3947
  if (requireVisible && !selector) {
4775
3948
  throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'require_visible requires selector.', { retryable: false });
4776
3949
  }
4777
- return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
3950
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4778
3951
  const selection = resolveBrowserEvaluationScope(page, input.args, connection);
4779
3952
  const observedUrl = selection.frame?.url ?? page.url();
4780
3953
  const checks: Array<Record<string, unknown>> = [];
@@ -4816,7 +3989,7 @@ async function executeBrowserPluginActionInternal(
4816
3989
  const target = resolveActionTarget(input.repoRoot, input.args);
4817
3990
  const file = screenshotFilePath(input.repoRoot, 'screenshot', target.sessionId, target.url);
4818
3991
  const selector = stringValue(input.args.selector);
4819
- const screenshot = await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
3992
+ const screenshot = await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4820
3993
  let bytes: number;
4821
3994
  if (selector && page.locator) {
4822
3995
  bytes = (await page.locator(selector).screenshot({ path: file })).length;
@@ -4842,7 +4015,7 @@ async function executeBrowserPluginActionInternal(
4842
4015
  case 'snapshot_interactive': {
4843
4016
  const target = resolveActionTarget(input.repoRoot, input.args);
4844
4017
  const limit = Math.min(positiveNumber(input.args.limit, 50), 200);
4845
- return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4018
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4846
4019
  const script = input.actionId === 'extract_links'
4847
4020
  ? EXTRACTION_SCRIPTS.links(limit)
4848
4021
  : input.actionId === 'extract_tables'
@@ -4890,7 +4063,7 @@ async function executeBrowserPluginActionInternal(
4890
4063
  case 'get_console_errors':
4891
4064
  case 'get_failed_requests': {
4892
4065
  const target = resolveActionTarget(input.repoRoot, input.args);
4893
- return await withPage(input.repoRoot, current, target, input.args, async (_page, diagnostics, connection) => {
4066
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (_page, diagnostics, connection) => {
4894
4067
  if (connection.provider === 'macos-apple-events') {
4895
4068
  throw new AssistantPluginError(
4896
4069
  'PLUGIN_BROWSER_DIAGNOSTICS_UNAVAILABLE',
@@ -4912,7 +4085,7 @@ async function executeBrowserPluginActionInternal(
4912
4085
  }
4913
4086
  case 'activate_page': {
4914
4087
  const target = resolveActionTarget(input.repoRoot, input.args);
4915
- return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4088
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4916
4089
  const waitMs = positiveNumber(input.args.post_action_wait_ms, DEFAULT_POST_ACTION_WAIT_MS);
4917
4090
  if (connection.provider === 'macos-apple-events') {
4918
4091
  await establishAuthoritativeNativeForeground(input, page, connection, target, waitMs);
@@ -4932,7 +4105,7 @@ async function executeBrowserPluginActionInternal(
4932
4105
  if (text.length > 200) {
4933
4106
  throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'text must be 200 characters or fewer.', { retryable: false });
4934
4107
  }
4935
- return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4108
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4936
4109
  const clicked = await page.evaluate((payload) => {
4937
4110
  const args = payload as { text: string };
4938
4111
  const normalize = (value: string) => value.replace(/\s+/g, ' ').trim();
@@ -4978,7 +4151,7 @@ async function executeBrowserPluginActionInternal(
4978
4151
  const selector = requiredString(input.args.selector, 'selector');
4979
4152
  const timeoutMs = positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS);
4980
4153
  try {
4981
- return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4154
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4982
4155
  if (input.actionId === 'click' && openShadowSelectorParts(selector).length > 1) {
4983
4156
  const parts = openShadowSelectorParts(selector);
4984
4157
  await page.evaluate((payload) => {
@@ -5037,7 +4210,7 @@ async function executeBrowserPluginActionInternal(
5037
4210
  const selector = requiredString(input.args.selector, 'selector');
5038
4211
  const text = requiredString(input.args.text, 'text');
5039
4212
  const timeoutMs = positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS);
5040
- return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4213
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
5041
4214
  if (input.actionId === 'fill' || !page.type) await page.fill(selector, text, { timeout: timeoutMs });
5042
4215
  else await page.type(selector, text, { timeout: timeoutMs });
5043
4216
  await delay(positiveNumber(input.args.post_action_wait_ms, DEFAULT_POST_ACTION_WAIT_MS));
@@ -5052,7 +4225,7 @@ async function executeBrowserPluginActionInternal(
5052
4225
  const selector = requiredString(input.args.selector, 'selector');
5053
4226
  const values = Array.isArray(input.args.values) ? input.args.values.map(String) : [];
5054
4227
  if (values.length === 0) throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'values is required.', { retryable: false });
5055
- return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4228
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
5056
4229
  if (!page.selectOption) {
5057
4230
  throw new AssistantPluginError('PLUGIN_BROWSER_SELECT_OPTION_UNAVAILABLE', 'The active browser provider cannot select options safely.', { retryable: false });
5058
4231
  }
@@ -5068,7 +4241,7 @@ async function executeBrowserPluginActionInternal(
5068
4241
  const target = resolveActionTarget(input.repoRoot, input.args);
5069
4242
  const selector = requiredString(input.args.selector, 'selector');
5070
4243
  const key = requiredString(input.args.key, 'key');
5071
- return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4244
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
5072
4245
  await page.press(selector, key, { timeout: positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS) });
5073
4246
  await delay(positiveNumber(input.args.post_action_wait_ms, DEFAULT_POST_ACTION_WAIT_MS));
5074
4247
  return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'press', `Pressed ${key} on ${selector}.`, {
@@ -5080,7 +4253,7 @@ async function executeBrowserPluginActionInternal(
5080
4253
  case 'keyboard_shortcut': {
5081
4254
  const target = resolveActionTarget(input.repoRoot, input.args);
5082
4255
  const key = requiredString(input.args.key, 'key');
5083
- return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4256
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
5084
4257
  if (page.keyboard?.press) await page.keyboard.press(key);
5085
4258
  else await page.press('body', key, { timeout: positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS) });
5086
4259
  await delay(positiveNumber(input.args.post_action_wait_ms, DEFAULT_POST_ACTION_WAIT_MS));
@@ -5101,7 +4274,7 @@ async function executeBrowserPluginActionInternal(
5101
4274
  if (guardSelector && kind !== 'click' && kind !== 'drag') {
5102
4275
  throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'guard_selector is supported only for trusted_input click or drag.', { retryable: false });
5103
4276
  }
5104
- return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4277
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
5105
4278
  const nativeTrustedInput = connection.provider === 'macos-apple-events';
5106
4279
  if (nativeTrustedInput && !page.trustedInput) {
5107
4280
  throw new AssistantPluginError(
@@ -5236,7 +4409,7 @@ async function executeBrowserPluginActionInternal(
5236
4409
  if (!/^[A-Za-z][A-Za-z0-9:_-]{0,63}$/.test(event)) {
5237
4410
  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
4411
  }
5239
- return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4412
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
5240
4413
  const dispatched = await page.evaluate((payload) => {
5241
4414
  const args = payload as { selector: string; event: string };
5242
4415
  const element = document.querySelector(args.selector);
@@ -5257,7 +4430,7 @@ async function executeBrowserPluginActionInternal(
5257
4430
  const target = resolveActionTarget(input.repoRoot, input.args);
5258
4431
  const selector = requiredString(input.args.selector, 'selector');
5259
4432
  const state = waitForSelectorState(input.args.state);
5260
- return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4433
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
5261
4434
  await page.waitForSelector(selector, {
5262
4435
  state,
5263
4436
  timeout: positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS),
@@ -5304,7 +4477,7 @@ async function executeBrowserPluginActionInternal(
5304
4477
  }
5305
4478
  return lexicalPath;
5306
4479
  });
5307
- return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4480
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
5308
4481
  await page.evaluate((payload) => {
5309
4482
  const args = payload as { selector: string; count: number };
5310
4483
  const el = document.querySelector(args.selector) as HTMLInputElement | null;
@@ -5329,9 +4502,9 @@ async function executeBrowserPluginActionInternal(
5329
4502
  const target = resolveActionTarget(input.repoRoot, input.args);
5330
4503
  const selector = requiredString(input.args.selector, 'selector');
5331
4504
  const requestedName = stringValue(input.args.suggested_name);
5332
- const downloadDir = stateDir(input.repoRoot, 'downloads');
4505
+ const downloadDir = browserStateDir(input.repoRoot, 'downloads');
5333
4506
  mkdirSync(downloadDir, { recursive: true });
5334
- return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4507
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
5335
4508
  if (connection.provider === 'macos-apple-events') {
5336
4509
  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 });
5337
4510
  }