@moretea-labs/forge 1.8.1 → 1.8.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 (328) hide show
  1. package/AGENTS.md +5 -2
  2. package/CHANGELOG.md +20 -0
  3. package/README.en.md +24 -3
  4. package/README.md +10 -6
  5. package/README.zh-CN.md +5 -3
  6. package/THIRD_PARTY_NOTICES.md +1 -2
  7. package/adapters/chatgpt/browser-delivery-runtime.ts +5 -3
  8. package/adapters/chatgpt/provider-delivery.ts +1 -1
  9. package/adapters/chatgpt/work-conversation-binding-store.ts +20 -1
  10. package/adapters/computer/chatgpt-extension-target.ts +295 -50
  11. package/adapters/computer/chrome-extension/background.js +227 -71
  12. package/adapters/computer/chrome-extension/content.js +181 -157
  13. package/adapters/computer/chrome-extension/manifest.json +3 -1
  14. package/adapters/computer/desktop-operator-provider.ts +18 -6
  15. package/adapters/computer/interaction-target-authority.ts +39 -9
  16. package/adapters/mcp/access-mode.ts +18 -39
  17. package/adapters/mcp/auth.ts +2 -6
  18. package/adapters/mcp/instructions.ts +2 -2
  19. package/adapters/mcp/multi-repository.ts +7 -28
  20. package/adapters/mcp/runtime-gateway/context-adapter.ts +79 -40
  21. package/adapters/mcp/runtime-gateway/context-pack-sidecar.ts +7 -15
  22. package/adapters/mcp/runtime-gateway/execution-tools.ts +7 -13
  23. package/adapters/mcp/runtime-gateway/plugin-adapter.ts +16 -3
  24. package/adapters/mcp/runtime-gateway/process-tools.ts +3 -21
  25. package/adapters/mcp/runtime-gateway/router.ts +3 -5
  26. package/adapters/mcp/runtime-gateway/routing-policy.ts +17 -10
  27. package/adapters/mcp/runtime-gateway/runtime-observation-adapter.ts +1 -1
  28. package/adapters/mcp/runtime-gateway/runtime-tool-definitions.ts +14 -9
  29. package/adapters/mcp/runtime-gateway/runtime-tools.ts +1 -5
  30. package/adapters/mcp/runtime-gateway/scheduler-adapter.ts +36 -1
  31. package/adapters/mcp/runtime-gateway/status-inbox-adapter.ts +58 -4
  32. package/adapters/mcp/runtime-gateway/typescript-navigation-sidecar.ts +7 -3
  33. package/adapters/mcp/runtime-gateway/work-adapter.ts +16 -16
  34. package/adapters/mcp/runtime-gateway/work-input-wire-migration.ts +0 -17
  35. package/adapters/mcp/runtime-gateway/work-semantic-operations.ts +69 -12
  36. package/adapters/mcp/runtime-gateway/workflow-supervisor-adapter.ts +26 -2
  37. package/adapters/mcp/server.ts +24 -3
  38. package/adapters/mcp/setup.ts +4 -10
  39. package/adapters/mcp/tool-mapping/access-tools.ts +0 -6
  40. package/adapters/mcp/tool-mapping/legacy-tool-service.ts +672 -2935
  41. package/adapters/mcp/tool-mapping/post-finalize-work-attribution.ts +1 -1
  42. package/adapters/mcp/tool-mapping/repository-tools.ts +52 -86
  43. package/adapters/mcp/toolset-names.ts +11 -42
  44. package/adapters/mcp/toolset.ts +16 -90
  45. package/{src/cli → adapters}/mcp/transports/AGENTS.md +5 -5
  46. package/{src/cli → adapters}/mcp/transports/CLAUDE.md +5 -5
  47. package/adapters/mcp/transports/http-observation.ts +18 -6
  48. package/adapters/mcp/transports/http-post-lifecycle.ts +19 -0
  49. package/adapters/mcp/transports/http.ts +54 -14
  50. package/adapters/mcp/types.ts +0 -6
  51. package/assets/hooks/lib/session-state.sh +0 -38
  52. package/assets/hooks/post-bash.sh +1 -1
  53. package/assets/hooks/post-tool-observer.sh +0 -5
  54. package/assets/hooks/prompt-guard.sh +0 -55
  55. package/assets/partials/04-project-structure.partial.md +1 -1
  56. package/assets/partials/07-footer.partial.md +1 -1
  57. package/assets/partials-agents/02-operating-mode.partial.md +1 -1
  58. package/assets/partials-agents/08-deep-docs.partial.md +1 -1
  59. package/assets/reference-configs/agentic-development-flow.md +1 -1
  60. package/assets/reference-configs/external-tooling.md +0 -76
  61. package/assets/reference-configs/release-deploy.md +1 -1
  62. package/assets/templates/helpers/architecture-queue.sh +1 -1
  63. package/assets/templates/helpers/check-agent-tooling.sh +1 -327
  64. package/assets/templates/helpers/check-task-workflow.sh +1 -1
  65. package/assets/templates/helpers/ensure-task-workflow.sh +0 -14
  66. package/assets/templates/helpers/migrate-project-template.sh +2 -2
  67. package/assets/workflow-contract.v1.json +0 -2
  68. package/docs/README.md +5 -2
  69. package/docs/architecture/CURRENT.md +114 -30
  70. package/docs/architecture/decisions/20260904-ios-device-migration-baseline.md +42 -0
  71. package/docs/architecture/decisions/20260918-stable-candidate-release-lifecycle.md +15 -2
  72. package/docs/architecture/decisions/20260924-thin-semantic-working-context.md +4 -2
  73. package/docs/architecture/decisions/20261001-supervisor-turn-authority.md +109 -4
  74. package/docs/architecture/decisions/20261009-exact-code-context-discovery.md +19 -0
  75. package/docs/architecture/decisions/20261009-generated-xcode-cache-retention.md +37 -0
  76. package/docs/architecture/decisions/20261011-autonomous-continuation-convergence.md +597 -0
  77. package/docs/architecture/decisions/20261017-edit-session-finalization-reconciliation.md +17 -0
  78. package/docs/architecture/decisions/20261019-retire-host-skills.md +16 -0
  79. package/docs/architecture/diagrams/agentic-dev-plugin-architecture.html +1 -1
  80. package/docs/architecture/domains/public-surface.md +3 -5
  81. package/docs/architecture/domains/verification.md +5 -5
  82. package/docs/architecture/modules/controller-runtime/transport-lifecycle.md +2 -2
  83. package/docs/architecture/requests/2026-09-04-computer-plugin-workflow-taxonomy.md +4 -4
  84. package/docs/architecture/reviews/20261015-code-search-runtime-acceptance.md +75 -0
  85. package/docs/forge-plugin-management.md +14 -2
  86. package/docs/operations/controller-browser-plugin.md +23 -0
  87. package/docs/operations/features.md +6 -7
  88. package/docs/operations/features.zh-CN.md +1 -1
  89. package/docs/operations/github-repository.md +4 -4
  90. package/docs/operations/github-semantic-release-projection.md +20 -0
  91. package/docs/operations/homebrew.md +2 -2
  92. package/docs/operations/mcp-tool-exposure.md +13 -54
  93. package/docs/operations/runtime-performance-diagnostics.md +8 -0
  94. package/docs/operations/standalone-disaster-recovery.md +1 -1
  95. package/docs/tutorials/01-install-and-start.zh-CN.md +1 -1
  96. package/docs/tutorials/03-first-repository-task.md +2 -0
  97. package/docs/wiki/Computer-and-Plugins.md +78 -0
  98. package/docs/wiki/Computer-and-Plugins.zh-CN.md +88 -0
  99. package/docs/wiki/Home.md +16 -2
  100. package/docs/wiki/Implementation.md +24 -12
  101. package/docs/wiki/Installation.md +28 -32
  102. package/docs/wiki/Integrations.md +4 -0
  103. package/docs/wiki/Quick-Start.md +35 -22
  104. package/docs/wiki/Releases-and-Upgrades.md +15 -25
  105. package/docs/wiki/_Sidebar.md +5 -1
  106. package/package.json +1 -12
  107. package/packages/kernel/controller/infrastructure/controller-session-store.ts +63 -38
  108. package/packages/kernel/identity/infrastructure/activity-projection-store.ts +10 -0
  109. package/packages/kernel/scheduler/application/eligibility.ts +4 -1
  110. package/packages/kernel/scheduler/infrastructure/schedule-store.ts +13 -10
  111. package/packages/kernel/work/application/work-service.ts +1 -0
  112. package/packages/kernel/work/domain/repository-completion-receipt.ts +1 -1
  113. package/packages/kernel/work/domain/state-machine.ts +9 -2
  114. package/packages/kernel/work/infrastructure/work-contract-store.ts +28 -1
  115. package/packages/plugin-runtime/browser/provider-registry.ts +12 -36
  116. package/packages/plugin-runtime/computer/conversation-target.ts +17 -7
  117. package/scripts/assemble-template.ts +11 -72
  118. package/scripts/check-mcp-compatibility.ts +55 -74
  119. package/scripts/check-package-identity.mjs +0 -5
  120. package/scripts/check-runtime-architecture.mjs +305 -28
  121. package/scripts/init-project.sh +0 -46
  122. package/scripts/install-ios-hid-toolchain.sh +60 -0
  123. package/scripts/install-standalone-recovery.ts +1 -1
  124. package/scripts/lib/project-init-lib.sh +3 -3
  125. package/scripts/migrate-windows-wsl-controller-home.ts +2 -0
  126. package/scripts/open-source-audit-allowlist.txt +0 -1
  127. package/scripts/run-governed-gate.ts +2 -0
  128. package/scripts/smoke-ios-physical-device.ts +38 -10
  129. package/scripts/smoke-mcp-http-runtime.ts +7 -8
  130. package/scripts/smoke-mcp-tool-surface.ts +27 -41
  131. package/scripts/smoke-workflow-supervisor-chrome-adapter.ts +6 -3
  132. package/scripts/sync-generated-authority.mjs +0 -1
  133. package/src/cli/agent-jobs/executor-health.ts +1 -1
  134. package/src/cli/agent-jobs/integration.ts +2 -2
  135. package/src/cli/chatgpt-browser/file-policy.ts +2 -2
  136. package/src/cli/commands/doctor.ts +1 -143
  137. package/src/cli/commands/global-runtime.ts +3 -196
  138. package/src/cli/commands/init-hook.ts +6 -23
  139. package/src/cli/commands/init.ts +3 -252
  140. package/src/cli/commands/mcp.ts +0 -5
  141. package/src/cli/commands/prompt-guard-decision.ts +0 -2
  142. package/src/cli/commands/recovery.ts +2 -14
  143. package/src/cli/commands/repository.ts +17 -1
  144. package/src/cli/commands/setup-execution.ts +41 -0
  145. package/src/cli/controller/completion-orchestrator.ts +1 -1
  146. package/src/cli/controller/composite-operations.ts +1 -1
  147. package/src/cli/controller/context/instruction-context.ts +1 -1
  148. package/src/cli/controller/context/known-paths.ts +2 -2
  149. package/src/cli/controller/context/query-planning.ts +11 -4
  150. package/src/cli/controller/context/rule-resolution.ts +2 -2
  151. package/src/cli/controller/context/source-materializer.ts +2 -2
  152. package/src/cli/controller/context/structural-provider.ts +84 -0
  153. package/src/cli/controller/context/structural.ts +6 -6
  154. package/src/cli/controller/context/types.ts +8 -9
  155. package/src/cli/controller/context-pack.ts +97 -76
  156. package/src/cli/controller/operational-plan.ts +1 -1
  157. package/src/cli/controller/systemd-user.ts +4 -10
  158. package/src/cli/editing/edit-session.ts +17 -5
  159. package/src/cli/github/github.ts +4 -4
  160. package/src/cli/github/semantic-projection.ts +174 -0
  161. package/src/cli/hook/prompt-intents.ts +0 -9
  162. package/src/cli/index.ts +1 -43
  163. package/src/cli/local-bridge/connector-freshness.ts +1 -12
  164. package/src/cli/local-bridge/console-automations.ts +1 -1
  165. package/src/cli/local-bridge/console-view-models.ts +1 -1
  166. package/src/cli/local-bridge/facade-api.ts +0 -2
  167. package/src/cli/repositories/command-classifier.ts +0 -9
  168. package/src/cli/repositories/command-scope.ts +2 -2
  169. package/src/cli/repositories/locks.ts +20 -0
  170. package/src/cli/repositories/registry.ts +2 -1
  171. package/src/cli/repositories/safe-patch.ts +2 -12
  172. package/src/cli/repositories/structured-git.ts +39 -3
  173. package/src/cli/repository/inspector.ts +185 -343
  174. package/src/effects/process-runner.ts +17 -1
  175. package/src/runtime/context/generic-lsp-provider.ts +1 -1
  176. package/src/runtime/context/semantic-navigation-contract.ts +7 -0
  177. package/src/runtime/context/semantic-navigation.ts +1 -1
  178. package/src/runtime/control-plane/cleanup-artifact-retention.ts +4 -4
  179. package/src/runtime/control-plane/execution/work-delivery-service.ts +150 -31
  180. package/src/runtime/control-plane/execution/work-handle-authority.ts +12 -4
  181. package/src/runtime/control-plane/execution/work-handle-store.ts +53 -1
  182. package/src/runtime/control-plane/execution/work-operation-service.ts +20 -10
  183. package/src/runtime/control-plane/execution/work-preparation-service.ts +108 -4
  184. package/src/runtime/control-plane/execution/work-process-evidence.ts +2 -2
  185. package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +28 -13
  186. package/src/runtime/control-plane/execution/work-validation-reconciler.ts +5 -1
  187. package/src/runtime/control-plane/execution/work-verification-snapshot.ts +1 -1
  188. package/src/runtime/control-plane/facade/capability-registry.ts +2 -0
  189. package/src/runtime/control-plane/facade/work-continuation.ts +1 -1
  190. package/src/runtime/control-plane/generated-cache-retention.ts +114 -63
  191. package/src/runtime/control-plane/global-scheduler/maintenance.ts +20 -8
  192. package/src/runtime/control-plane/global-scheduler/reconciliation.ts +1 -11
  193. package/src/runtime/control-plane/governance/external-effects.ts +1 -1
  194. package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +1 -0
  195. package/src/runtime/control-plane/runtime-cleanup.ts +12 -93
  196. package/src/runtime/evidence/sensitive-output.ts +22 -2
  197. package/src/runtime/execution/jobs/child-reference.ts +11 -0
  198. package/src/runtime/execution/jobs/receipt-recovery.ts +1 -11
  199. package/src/runtime/execution/process-runtime/command-facade.ts +3 -3
  200. package/src/runtime/execution/process-runtime/runtime.ts +20 -16
  201. package/src/runtime/execution/thin-harness/execution-router.ts +16 -16
  202. package/src/runtime/execution/thin-harness/fast-executor.ts +2 -2
  203. package/src/runtime/execution/thin-harness/search-worker.ts +2 -2
  204. package/src/runtime/execution/workers/executor.ts +1 -2
  205. package/src/runtime/maintenance/cleanup.ts +41 -5
  206. package/src/runtime/plugins/action-application.ts +12 -1
  207. package/src/runtime/plugins/app-store-connect-adapter.ts +252 -4
  208. package/src/runtime/plugins/browser-adapter.ts +381 -313
  209. package/src/runtime/plugins/browser-macos-bridge.ts +114 -112
  210. package/src/runtime/plugins/browser-manifest-surface.ts +2 -1
  211. package/src/runtime/plugins/browser-registration.ts +2 -0
  212. package/src/runtime/plugins/browser-runtime.ts +26 -35
  213. package/src/runtime/plugins/computer-chatgpt-browser-adapter-installer.ts +11 -2
  214. package/src/runtime/plugins/computer-registration.ts +76 -9
  215. package/src/runtime/plugins/github-adapter.ts +30 -3
  216. package/src/runtime/plugins/gmail-adapter.ts +166 -1
  217. package/src/runtime/plugins/google-shared.ts +10 -5
  218. package/src/runtime/plugins/ios/remote-xpc-hid.ts +70 -30
  219. package/src/runtime/plugins/ios-adapter.ts +45 -40
  220. package/src/runtime/plugins/ios-agent-device.ts +54 -4
  221. package/src/runtime/plugins/ios-device-ownership.ts +103 -0
  222. package/src/runtime/plugins/ios-physical-device.ts +497 -79
  223. package/src/runtime/plugins/local-system-adapter.ts +1 -99
  224. package/src/runtime/plugins/store.ts +80 -7
  225. package/src/runtime/plugins/types.ts +4 -0
  226. package/src/runtime/projections/git-status-sampler.ts +2 -19
  227. package/src/runtime/recovery/actions.ts +1 -1
  228. package/src/runtime/recovery/lifecycle-inventory.ts +1 -11
  229. package/src/runtime/recovery/probe.ts +2 -2
  230. package/src/runtime/release/release-coordinator.ts +20 -4
  231. package/src/runtime/root/controller-progression-composition.ts +67 -5
  232. package/src/runtime/root/package-connector-service.ts +123 -11
  233. package/src/runtime/root/package-runtime-release.ts +6 -18
  234. package/src/runtime/root/package-runtime-service.ts +7 -13
  235. package/src/runtime/root/release-execution-canary.ts +18 -5
  236. package/src/runtime/root/release-manifest.ts +0 -34
  237. package/src/runtime/root/release-materialize.ts +0 -78
  238. package/src/runtime/root/runtime.ts +1 -2
  239. package/src/runtime/root/scheduled-controller-composition.ts +2 -1
  240. package/src/runtime/root/service.ts +90 -11
  241. package/src/runtime/root/types.ts +0 -6
  242. package/src/runtime/root/workflow-supervisor-composition.ts +207 -19
  243. package/src/runtime/root/workflow-supervisor-runtime.ts +365 -10
  244. package/src/runtime/safe-tooling/google-credential-store.ts +1 -11
  245. package/src/runtime/safe-tooling/ios-development.ts +22 -8
  246. package/src/runtime/safe-tooling/job-summary.ts +1 -1
  247. package/src/runtime/safe-tooling/workspace-auth.ts +4 -1
  248. package/src/runtime/shared/atomic-text-file.ts +21 -0
  249. package/src/runtime/shared/json-files.ts +8 -3
  250. package/src/runtime/standalone-recovery/core.ts +189 -174
  251. package/src/runtime/standalone-recovery/entry.ts +62 -30
  252. package/src/runtime/standalone-recovery/operation-lock.ts +11 -1
  253. package/src/runtime/workflow/schedules/chatgpt-automation-migration.ts +1 -0
  254. package/src/runtime/workflow/schedules/engine.ts +36 -14
  255. package/src/runtime/workflow/schedules/pending-activation.ts +11 -4
  256. package/src/runtime/workflow/schedules/work-continuation.ts +51 -6
  257. package/supervisor/client.ts +31 -14
  258. package/supervisor/control-plane.ts +210 -115
  259. package/supervisor/forge-validators.ts +88 -21
  260. package/supervisor/native-browser-adapter.ts +393 -28
  261. package/supervisor/native-messaging/host.ts +21 -2
  262. package/supervisor/protocol.ts +74 -26
  263. package/supervisor/server.ts +16 -8
  264. package/supervisor/store.ts +476 -57
  265. package/supervisor/types.ts +19 -13
  266. package/SKILL.md +0 -427
  267. package/adapters/computer/chatgpt-target-router.ts +0 -103
  268. package/adapters/mcp/runtime-gateway/automation-receipt-adapter.ts +0 -87
  269. package/adapters/mcp/runtime-gateway/work-validation-reconciler.ts +0 -1
  270. package/adapters/mcp/toolset-selection.ts +0 -41
  271. package/agents/openai.yaml +0 -4
  272. package/assets/skill-hooks.json +0 -41
  273. package/assets/skill-version.json +0 -189
  274. package/assets/skills/claude-review/SKILL.md +0 -231
  275. package/assets/skills/codex-review/SKILL.md +0 -103
  276. package/assets/templates/helpers/check-skill-version.ts +0 -193
  277. package/scripts/check-skill-version.ts +0 -5
  278. package/scripts/ensure-codegraph.sh +0 -16
  279. package/scripts/run-skill-hook.ts +0 -244
  280. package/scripts/sync-codex-installed-copies.sh +0 -182
  281. package/src/cli/commands/tools.ts +0 -167
  282. package/src/cli/mcp/access-mode.ts +0 -2
  283. package/src/cli/mcp/access-tools.ts +0 -2
  284. package/src/cli/mcp/audit.ts +0 -2
  285. package/src/cli/mcp/auth.ts +0 -2
  286. package/src/cli/mcp/instructions.ts +0 -2
  287. package/src/cli/mcp/legacy-context.ts +0 -2
  288. package/src/cli/mcp/legacy-tool-service.ts +0 -2
  289. package/src/cli/mcp/multi-repository.ts +0 -2
  290. package/src/cli/mcp/oauth.ts +0 -2
  291. package/src/cli/mcp/openai-secure-tunnel.ts +0 -2
  292. package/src/cli/mcp/paths.ts +0 -2
  293. package/src/cli/mcp/policy.ts +0 -2
  294. package/src/cli/mcp/post-finalize-work-attribution.ts +0 -2
  295. package/src/cli/mcp/proxy-env.ts +0 -2
  296. package/src/cli/mcp/readiness-projection.ts +0 -2
  297. package/src/cli/mcp/redaction.ts +0 -2
  298. package/src/cli/mcp/repo.ts +0 -2
  299. package/src/cli/mcp/repository-tools.ts +0 -2
  300. package/src/cli/mcp/server.ts +0 -2
  301. package/src/cli/mcp/setup.ts +0 -2
  302. package/src/cli/mcp/tool-capability-cache.ts +0 -2
  303. package/src/cli/mcp/tool-contract.ts +0 -2
  304. package/src/cli/mcp/tools.ts +0 -2
  305. package/src/cli/mcp/toolset-names.ts +0 -2
  306. package/src/cli/mcp/toolset-selection.ts +0 -2
  307. package/src/cli/mcp/toolset.ts +0 -2
  308. package/src/cli/mcp/transports/http.ts +0 -2
  309. package/src/cli/mcp/transports/session-registry.ts +0 -2
  310. package/src/cli/mcp/transports/stdio.ts +0 -2
  311. package/src/cli/mcp/types.ts +0 -2
  312. package/src/cli/mcp/umbrella-tools.ts +0 -2
  313. package/src/cli/tools/codegraph.ts +0 -695
  314. package/src/core/adoption/helper-wrapper-plan.ts +0 -159
  315. package/src/runtime/context/codegraph-cache-boundary.ts +0 -255
  316. package/src/runtime/context/codegraph-read-provider.ts +0 -611
  317. package/src/runtime/context/codegraph-sidecar.cjs +0 -248
  318. package/src/runtime/control-plane/codegraph-cache-retention.ts +0 -164
  319. package/src/runtime/gateway/mcp/execution-tools.ts +0 -2
  320. package/src/runtime/gateway/mcp/legacy-ios-tool-adapter.ts +0 -2
  321. package/src/runtime/gateway/mcp/persisted-check-process.ts +0 -2
  322. package/src/runtime/gateway/mcp/process-tools.ts +0 -2
  323. package/src/runtime/gateway/mcp/router.ts +0 -2
  324. package/src/runtime/gateway/mcp/runtime-tool-definitions.ts +0 -2
  325. package/src/runtime/gateway/mcp/runtime-tools.ts +0 -2
  326. package/src/runtime/gateway/mcp/work-validation-reconciler.ts +0 -2
  327. package/src/runtime/plugins/browser-provider-registry.ts +0 -2
  328. package/src/runtime/plugins/computer-chatgpt-macos-target.ts +0 -891
@@ -102,6 +102,9 @@ implements ComputerChatgptConversationTargetPort, ComputerChatgptExtensionBroker
102
102
  private readonly providerStates = new Map<string, ProviderState>();
103
103
  private readonly pending = new Map<string, PendingCommand>();
104
104
  private readonly queues = new Map<string, string[]>();
105
+ // Process-local physical-operation singleflight, not a second tab lifecycle
106
+ // authority. Exact Surface binding/CAS remains the only durable owner.
107
+ private readonly retiring = new Map<string, Promise<boolean>>();
105
108
 
106
109
  constructor(
107
110
  private readonly controllerHome: string,
@@ -183,11 +186,15 @@ implements ComputerChatgptConversationTargetPort, ComputerChatgptExtensionBroker
183
186
 
184
187
  private selectProviderInstance(preferredInstanceId?: string): { instanceId?: string; ambiguous: boolean } {
185
188
  const fresh = this.freshProviders();
186
- if (preferredInstanceId && fresh.some(([instanceId]) => instanceId === preferredInstanceId)) {
187
- return { instanceId: preferredInstanceId, ambiguous: false };
189
+ if (preferredInstanceId) {
190
+ // A disconnected profile is unavailable, not permission to switch to
191
+ // the only other signed-in (or signed-out) Chrome instance.
192
+ return { ...(fresh.some(([instanceId]) => instanceId === preferredInstanceId)
193
+ ? { instanceId: preferredInstanceId } : {}), ambiguous: false };
188
194
  }
189
- const current = fresh.filter(([, state]) => state.heartbeat.conversations.some((conversation) => conversation.isCurrent));
190
- if (current.length === 1) return { instanceId: current[0]![0], ambiguous: false };
195
+ // Foreground tab is not a durable browser-principal binding. If more
196
+ // than one Extension instance is connected, require a known pin instead
197
+ // of guessing which profile should receive a new Supervisor effect.
191
198
  if (fresh.length === 1) return { instanceId: fresh[0]![0], ambiguous: false };
192
199
  return { ambiguous: fresh.length > 1 };
193
200
  }
@@ -239,7 +246,20 @@ implements ComputerChatgptConversationTargetPort, ComputerChatgptExtensionBroker
239
246
  const record = this.upsert(identity);
240
247
  const normalized = normalizeBinding(binding, instanceId);
241
248
  if (!normalized) return record;
242
- return await this.authority.withSurfaceLease(this.controllerHome, record.targetId, async (lease) => lease.bind(normalized));
249
+ return await this.authority.withSurfaceLease(this.controllerHome, record.targetId, async (lease) => {
250
+ const previous = lease.current().providerBinding;
251
+ // Heartbeat inventory omits the Forge-create token. Carry it forward only
252
+ // for the same exact physical attachment; never transfer it to a new tab.
253
+ const sameAttachment = previous?.providerId === normalized.providerId
254
+ && previous.providerSessionId === normalized.providerSessionId
255
+ && previous.browserProduct === normalized.browserProduct
256
+ && previous.windowId === normalized.windowId
257
+ && previous.tabId === normalized.tabId;
258
+ return lease.bind({
259
+ ...normalized,
260
+ ...(normalized.ownerToken ? {} : sameAttachment && previous?.ownerToken ? { ownerToken: previous.ownerToken } : {}),
261
+ });
262
+ });
243
263
  }
244
264
 
245
265
  private target(identity: ComputerChatgptTargetIdentity, record: ComputerSurfaceTarget, instanceId: string): ComputerChatgptConversationTarget {
@@ -250,7 +270,19 @@ implements ComputerChatgptConversationTargetPort, ComputerChatgptExtensionBroker
250
270
  const response = await this.submit(instanceId, { kind: 'observe', identity, ...(record.providerBinding ? { providerBinding: record.providerBinding } : {}), ...(options ? { options } : {}) });
251
271
  if (!response.result) throw new Error(response.claimed ? 'COMPUTER_CHATGPT_EXTENSION_OBSERVATION_OUTCOME_UNKNOWN' : 'COMPUTER_CHATGPT_EXTENSION_NOT_CONNECTED');
252
272
  if (response.result.kind === 'observation') {
253
- if (response.result.providerBinding) await this.bindAsync(identity, response.result.providerBinding, instanceId);
273
+ if (response.result.providerBinding) {
274
+ const observed = normalizeBinding(response.result.providerBinding, instanceId);
275
+ const saved = this.authority.getSurface(this.controllerHome, record.targetId)?.providerBinding;
276
+ // A newer observedAt is not a new physical binding. Normal reads
277
+ // must not rewrite the Surface and its binding indexes on every poll.
278
+ if (!observed || !saved || saved.providerId !== observed.providerId
279
+ || saved.providerSessionId !== observed.providerSessionId
280
+ || saved.browserProduct !== observed.browserProduct
281
+ || saved.windowId !== observed.windowId || saved.tabId !== observed.tabId
282
+ || (observed.ownerToken && saved.ownerToken !== observed.ownerToken)) {
283
+ await this.bindAsync(identity, response.result.providerBinding, instanceId);
284
+ }
285
+ }
254
286
  return response.result.observation;
255
287
  }
256
288
  if (response.result.kind === 'failed') throw new Error(response.result.code);
@@ -259,29 +291,30 @@ implements ComputerChatgptConversationTargetPort, ComputerChatgptExtensionBroker
259
291
  prepare: async (prompt, options) => {
260
292
  const response = await this.submit(instanceId, { kind: 'prepare', identity, ...(record.providerBinding ? { providerBinding: record.providerBinding } : {}), prompt,
261
293
  ...(options?.mode ? { mode: options.mode } : {}),
262
- ...(options?.reasoning ? { reasoning: options.reasoning } : {}),
263
294
  });
264
295
  if (!response.result) return { ready: false, reasonCode: response.claimed
265
296
  ? 'COMPUTER_CHATGPT_EXTENSION_PREPARE_OUTCOME_UNKNOWN' : 'COMPUTER_CHATGPT_EXTENSION_NOT_CONNECTED' };
266
- if (response.result.kind === 'prepared') return response.result.ready
267
- ? { ready: true } : { ready: false, reasonCode: response.result.reasonCode };
297
+ if (response.result.kind === 'prepared') {
298
+ if (!response.result.ready) return { ready: false, reasonCode: response.result.reasonCode };
299
+ return { ready: true };
300
+ }
268
301
  return { ready: false, reasonCode: response.result.kind === 'failed'
269
302
  ? response.result.code : 'COMPUTER_CHATGPT_EXTENSION_PREPARE_INVALID' };
270
303
  },
271
304
  dispatch: async (prompt, options) => {
272
- const response = await this.submit(instanceId, { kind: 'dispatch', identity, ...(record.providerBinding ? { providerBinding: record.providerBinding } : {}), prompt, ...(options?.mode ? { mode: options.mode } : {}), ...(options?.reasoning ? { reasoning: options.reasoning } : {}) });
305
+ const response = await this.submit(instanceId, { kind: 'dispatch', identity, ...(record.providerBinding ? { providerBinding: record.providerBinding } : {}), prompt, ...(options?.mode ? { mode: options.mode } : {}) });
273
306
  if (!response.result) {
274
307
  return response.claimed
275
308
  ? { mutation: 'attempted' as const }
276
309
  : { mutation: 'not_attempted' as const, reasonCode: 'COMPUTER_CHATGPT_EXTENSION_NOT_CONNECTED' };
277
310
  }
278
311
  if (response.result.kind === 'dispatch') {
279
- return response.result.mutation === 'attempted'
280
- ? { mutation: 'attempted' as const, ...(response.result.confirmed === undefined ? {} : { confirmed: response.result.confirmed }), ...(response.result.reasoningVerified ? { reasoningVerified: response.result.reasoningVerified } : {}) }
281
- : { mutation: 'not_attempted' as const, reasonCode: response.result.reasonCode };
312
+ if (response.result.mutation === 'attempted') return { mutation: 'attempted' as const, ...(response.result.confirmed === undefined ? {} : { confirmed: response.result.confirmed }) };
313
+ if (response.result.mutation === 'not_attempted') return { mutation: 'not_attempted' as const, reasonCode: response.result.reasonCode };
282
314
  }
283
- if (response.result.kind === 'failed') return { mutation: 'not_attempted' as const, reasonCode: response.result.code };
284
- return { mutation: 'not_attempted' as const, reasonCode: 'COMPUTER_CHATGPT_EXTENSION_DISPATCH_INVALID' };
315
+ // Only a typed dispatch/not_attempted result proves no Send click. A
316
+ // generic failure or wrong response kind cannot authorize another send.
317
+ return { mutation: 'attempted' as const, confirmed: false };
285
318
  },
286
319
  };
287
320
  }
@@ -299,21 +332,44 @@ implements ComputerChatgptConversationTargetPort, ComputerChatgptExtensionBroker
299
332
  return { conversations: [...byConversationId.values()], complete: true, unavailableProviders: [] };
300
333
  }
301
334
 
302
- async ensureExact(identity: ComputerChatgptConversationIdentity): Promise<ComputerChatgptTargetResult> {
335
+ async ensureExact(identity: ComputerChatgptConversationIdentity, options: { createIfMissing?: boolean } = {}): Promise<ComputerChatgptTargetResult> {
336
+ const preferredInstanceId = this.preferredProviderInstance(identity);
303
337
  const fresh = this.freshProviders();
304
- if (fresh.length === 0) return failure('COMPUTER_CHATGPT_EXTENSION_NOT_CONNECTED', { failoverSafe: true });
305
- const exact = fresh.flatMap(([instanceId, state]) => state.heartbeat.conversations
338
+ if (fresh.length === 0 || (preferredInstanceId && !fresh.some(([instanceId]) => instanceId === preferredInstanceId))) {
339
+ return failure('COMPUTER_CHATGPT_EXTENSION_NOT_CONNECTED', { failoverSafe: !preferredInstanceId });
340
+ }
341
+ const exact = fresh.filter(([instanceId]) => !preferredInstanceId || instanceId === preferredInstanceId)
342
+ .flatMap(([instanceId, state]) => state.heartbeat.conversations
306
343
  .filter((entry) => entry.conversationId === identity.conversationId)
307
344
  .map((entry) => ({ instanceId, entry })));
308
- if (exact.length > 1) return failure('COMPUTER_CHATGPT_EXTENSION_EXACT_TARGET_AMBIGUOUS', { failoverSafe: false });
309
- if (exact.length === 1) {
310
- const candidate = exact[0]!;
345
+ // Multiple tabs can show one conversation. Prefer the existing exact
346
+ // Computer binding over heartbeat focus or an arbitrary matching tab.
347
+ const binding = this.authority.findSurfaceByStableIdentity(this.controllerHome, stableIdentity(identity))?.providerBinding;
348
+ let candidates = exact;
349
+ if (binding?.providerId === PROVIDER_ID && binding.windowId && binding.tabId) {
350
+ const bound = exact.filter(({ instanceId, entry }) => instanceId === binding.providerSessionId
351
+ && entry.providerBinding?.tabId === binding.tabId
352
+ && (!binding.browserProduct || entry.providerBinding?.browserProduct === binding.browserProduct));
353
+ // Chrome restart replaces physical tab IDs, not the profile principal or
354
+ // conversation identity. A unique exact tab observed by that SAME live
355
+ // provider may replace an absent pin through the Surface lease below.
356
+ // Never choose between multiple matching tabs or switch browser profiles.
357
+ const replacement = exact.length === 1 && exact[0]!.instanceId === binding.providerSessionId
358
+ && (!binding.browserProduct || exact[0]!.entry.providerBinding?.browserProduct === binding.browserProduct)
359
+ ? exact : [];
360
+ candidates = bound.length ? bound : replacement;
361
+ }
362
+ // A stale binding must not turn two observed exact tabs into a third tab.
363
+ if (exact.length > 1 && candidates.length !== 1) return failure('COMPUTER_CHATGPT_EXTENSION_EXACT_TARGET_AMBIGUOUS', { failoverSafe: false });
364
+ if (candidates.length === 1) {
365
+ const candidate = candidates[0]!;
366
+ if (!candidate.entry.providerBinding) return failure('COMPUTER_CHATGPT_EXTENSION_PHYSICAL_TAB_UNVERIFIED', { failoverSafe: false });
311
367
  const record = await this.bindAsync(identity, candidate.entry.providerBinding, candidate.instanceId);
312
368
  const target = this.target(identity, record, candidate.instanceId);
313
369
  try {
314
370
  const observation = await target.observe({ includeUserHistory: false, includePageText: false });
315
371
  if (!conversationContentAvailable(observation)) {
316
- return failure('COMPUTER_CHATGPT_CONVERSATION_CONTENT_UNAVAILABLE', { failoverSafe: true });
372
+ return failure('COMPUTER_CHATGPT_CONVERSATION_CONTENT_UNAVAILABLE', { failoverSafe: false });
317
373
  }
318
374
  return { state: 'ready', target, observation };
319
375
  } catch (error) {
@@ -321,15 +377,49 @@ implements ComputerChatgptConversationTargetPort, ComputerChatgptExtensionBroker
321
377
  error instanceof Error && error.message.trim()
322
378
  ? error.message.trim()
323
379
  : 'COMPUTER_CHATGPT_EXTENSION_OBSERVATION_UNAVAILABLE',
324
- { failoverSafe: true },
380
+ { failoverSafe: false },
325
381
  );
326
382
  }
327
383
  }
328
- const selected = this.selectProviderInstance(this.preferredProviderInstance(identity));
384
+ // A heartbeat is an inventory hint, not the exact-target authority.
385
+ // When it has no match, let the SAME live Chrome instance perform one
386
+ // read-only physical-tab lookup. This does not create a tab or replay Send;
387
+ // the provider refuses ambiguous matches and drifted pinned tabs.
388
+ if (options.createIfMissing === false) {
389
+ const selected = this.selectProviderInstance(preferredInstanceId);
390
+ if (!selected.instanceId) return failure(
391
+ selected.ambiguous ? 'COMPUTER_CHATGPT_EXTENSION_PROVIDER_INSTANCE_AMBIGUOUS' : 'COMPUTER_CHATGPT_EXTENSION_NOT_CONNECTED',
392
+ { failoverSafe: false },
393
+ );
394
+ const response = await this.submit(selected.instanceId, { kind: 'observe', identity,
395
+ ...(binding?.providerId === PROVIDER_ID ? { providerBinding: binding } : {}),
396
+ options: { includeUserHistory: false, includePageText: false },
397
+ });
398
+ if (!response.result) return failure('COMPUTER_CHATGPT_EXTENSION_OBSERVATION_UNAVAILABLE', { failoverSafe: false });
399
+ if (response.result.kind === 'observation') {
400
+ const { observation, providerBinding } = response.result;
401
+ let exactUrl = false;
402
+ try {
403
+ const parsed = new URL(observation.url);
404
+ exactUrl = parsed.origin === 'https://chatgpt.com'
405
+ && parsed.pathname.endsWith(`/c/${identity.conversationId}`);
406
+ } catch { /* Never bind an unparseable or changed conversation target. */ }
407
+ if (!exactUrl || !providerBinding) return failure('COMPUTER_CHATGPT_EXTENSION_TARGET_IDENTITY_UNVERIFIED', { failoverSafe: false });
408
+ if (!conversationContentAvailable(observation)) return failure('COMPUTER_CHATGPT_CONVERSATION_CONTENT_UNAVAILABLE', { failoverSafe: false });
409
+ const record = await this.bindAsync(identity, providerBinding, selected.instanceId);
410
+ return { state: 'ready', target: this.target(identity, record, selected.instanceId), observation };
411
+ }
412
+ return failure(response.result.kind === 'failed'
413
+ ? response.result.code === 'COMPUTER_CHATGPT_EXTENSION_TARGET_MISSING'
414
+ ? 'COMPUTER_CHATGPT_EXTENSION_EXACT_TARGET_ABSENT_READ_ONLY'
415
+ : response.result.code
416
+ : 'COMPUTER_CHATGPT_EXTENSION_OBSERVATION_INVALID', { failoverSafe: false });
417
+ }
418
+ const selected = this.selectProviderInstance(preferredInstanceId);
329
419
  if (!selected.instanceId) {
330
420
  return failure(
331
421
  selected.ambiguous ? 'COMPUTER_CHATGPT_EXTENSION_PROVIDER_INSTANCE_AMBIGUOUS' : 'COMPUTER_CHATGPT_EXTENSION_NOT_CONNECTED',
332
- { failoverSafe: !selected.ambiguous },
422
+ { failoverSafe: !selected.ambiguous && !preferredInstanceId },
333
423
  );
334
424
  }
335
425
  const previous = this.authority.findSurfaceByStableIdentity(this.controllerHome, stableIdentity(identity));
@@ -338,44 +428,96 @@ implements ComputerChatgptConversationTargetPort, ComputerChatgptExtensionBroker
338
428
  });
339
429
  if (!response.result) return failure(
340
430
  response.claimed ? 'COMPUTER_CHATGPT_EXTENSION_TARGET_OPEN_OUTCOME_UNKNOWN' : 'COMPUTER_CHATGPT_EXTENSION_NOT_CONNECTED',
341
- { failoverSafe: !response.claimed },
431
+ { failoverSafe: !response.claimed && !preferredInstanceId },
342
432
  );
343
433
  if (response.result.kind === 'ensured') {
434
+ const record = await this.bindAsync(identity, response.result.providerBinding, selected.instanceId);
344
435
  if (response.result.observation && !conversationContentAvailable(response.result.observation)) {
345
- return failure('COMPUTER_CHATGPT_CONVERSATION_CONTENT_UNAVAILABLE', { failoverSafe: true });
436
+ return failure('COMPUTER_CHATGPT_CONVERSATION_CONTENT_UNAVAILABLE', { failoverSafe: false });
346
437
  }
347
- const record = await this.bindAsync(identity, response.result.providerBinding, selected.instanceId);
348
438
  return { state: 'ready', target: this.target(identity, record, selected.instanceId), ...(response.result.observation ? { observation: response.result.observation } : {}) };
349
439
  }
350
- if (response.result.kind === 'failed') return failure(response.result.code, { retryable: response.result.retryable, failoverSafe: response.result.failoverSafe !== false, humanAction: response.result.humanAction });
351
- return failure('COMPUTER_CHATGPT_EXTENSION_ENSURE_INVALID', { failoverSafe: true });
440
+ if (response.result.kind === 'failed') {
441
+ if (response.result.providerBinding) await this.bindAsync(identity, response.result.providerBinding, selected.instanceId);
442
+ return failure(response.result.code, { retryable: response.result.retryable,
443
+ failoverSafe: !response.result.providerBinding && !preferredInstanceId && response.result.failoverSafe === true,
444
+ humanAction: response.result.humanAction });
445
+ }
446
+ return failure('COMPUTER_CHATGPT_EXTENSION_ENSURE_INVALID', { failoverSafe: false });
352
447
  }
353
448
 
354
- async openBootstrap(projectUrl: string, bootstrapKey: string): Promise<ComputerChatgptTargetResult> {
449
+ async openBootstrap(projectUrl: string, bootstrapKey: string, recoverStalePin = true): Promise<ComputerChatgptTargetResult> {
355
450
  const identity: ComputerChatgptBootstrapIdentity = { namespace: 'chatgpt.bootstrap', bootstrapKey, projectUrl };
451
+ const previous = this.authority.findSurfaceByStableIdentity(this.controllerHome, stableIdentity(identity));
452
+ const binding = previous?.providerBinding;
453
+ // Existing bootstrap tab identity survives extension reconnects. A foreign
454
+ // provider owns its tab; extension must not create another one for the
455
+ // same task. Conversely, extension absence cannot authorize native tab
456
+ // creation while an extension-owned tab might still exist.
457
+ if (binding && binding.providerId !== PROVIDER_ID) {
458
+ return failure('COMPUTER_CHATGPT_EXTENSION_BOOTSTRAP_PROVIDER_MISMATCH', { retryable: false, failoverSafe: true });
459
+ }
356
460
  const selected = this.selectProviderInstance(this.preferredProviderInstance(identity));
357
461
  if (!selected.instanceId) {
358
462
  return failure(
359
463
  selected.ambiguous ? 'COMPUTER_CHATGPT_EXTENSION_PROVIDER_INSTANCE_AMBIGUOUS' : 'COMPUTER_CHATGPT_EXTENSION_NOT_CONNECTED',
360
- { failoverSafe: !selected.ambiguous },
464
+ { failoverSafe: !selected.ambiguous && !binding },
361
465
  );
362
466
  }
363
- // Controller is the only durable owner of a tab binding. A restarted
364
- // extension uses this exact attachment instead of claiming a Project tab.
365
- const previous = this.authority.findSurfaceByStableIdentity(this.controllerHome, stableIdentity(identity));
467
+ // Controller's exact tab attachment, not the Project URL, is recovery
468
+ // authority after a worker restart.
366
469
  const response = await this.submit(selected.instanceId, { kind: 'ensure', identity,
367
470
  ...(previous?.providerBinding?.providerId === PROVIDER_ID ? { providerBinding: previous.providerBinding } : {}),
368
471
  });
369
472
  if (!response.result) return failure(
370
473
  response.claimed ? 'COMPUTER_CHATGPT_EXTENSION_BOOTSTRAP_OPEN_OUTCOME_UNKNOWN' : 'COMPUTER_CHATGPT_EXTENSION_NOT_CONNECTED',
371
- { failoverSafe: !response.claimed },
474
+ { failoverSafe: !response.claimed && !binding },
372
475
  );
373
476
  if (response.result.kind === 'ensured') {
374
477
  const record = await this.bindAsync(identity, response.result.providerBinding, selected.instanceId);
375
478
  return { state: 'ready', target: this.target(identity, record, selected.instanceId), ...(response.result.observation ? { observation: response.result.observation } : {}) };
376
479
  }
377
- if (response.result.kind === 'failed') return failure(response.result.code, { retryable: response.result.retryable, failoverSafe: response.result.failoverSafe !== false, humanAction: response.result.humanAction });
378
- return failure('COMPUTER_CHATGPT_EXTENSION_BOOTSTRAP_INVALID', { failoverSafe: true });
480
+ if (response.result.kind === 'failed') {
481
+ if (response.result.providerBinding) await this.bindAsync(identity, response.result.providerBinding, selected.instanceId);
482
+ // Only openBootstrap's pre-dispatch caller may rebuild a tab attachment.
483
+ // The SAME live Chrome provider must prove that the exact pinned tab is
484
+ // either absent or now serves a DIFFERENT Project. A reused physical tab
485
+ // id is not a valid target and must not permanently strand the unspent
486
+ // bootstrap. Never close/navigate the mismatched foreign tab. Keep the
487
+ // one durable target and lease/CAS-clear only its stale binding; never
488
+ // recover from an unknown tab-create result or switch profiles/providers.
489
+ if (recoverStalePin && (response.result.code === 'COMPUTER_CHATGPT_BOOTSTRAP_PINNED_TAB_MISSING'
490
+ || response.result.code === 'COMPUTER_CHATGPT_BOOTSTRAP_PINNED_TAB_IDENTITY_CHANGED')
491
+ && previous && binding?.providerId === PROVIDER_ID
492
+ && providerInstanceId(binding.providerSessionId) === selected.instanceId
493
+ && binding.windowId && binding.tabId) {
494
+ try {
495
+ await this.authority.withSurfaceLease(this.controllerHome, previous.targetId, async (lease) => {
496
+ const current = lease.current();
497
+ const attached = current.providerBinding;
498
+ if (current.stableIdentity.resource?.namespace !== identity.namespace
499
+ || current.stableIdentity.resource.key !== identity.bootstrapKey
500
+ || attached?.providerId !== binding.providerId
501
+ || attached.providerSessionId !== binding.providerSessionId
502
+ || attached.windowId !== binding.windowId || attached.tabId !== binding.tabId
503
+ || attached.browserProduct !== binding.browserProduct) {
504
+ throw new Error('COMPUTER_CHATGPT_BOOTSTRAP_PINNED_BINDING_CHANGED');
505
+ }
506
+ lease.clearBinding();
507
+ });
508
+ } catch {
509
+ return failure('COMPUTER_CHATGPT_BOOTSTRAP_PINNED_BINDING_CHANGED', { failoverSafe: false });
510
+ }
511
+ // Exactly one same-provider re-open. A claimed but unanswered tabCreate
512
+ // still reports outcome unknown and must never create another tab.
513
+ return await this.openBootstrap(projectUrl, bootstrapKey, false);
514
+ }
515
+ return failure(response.result.code, {
516
+ retryable: response.result.retryable, failoverSafe: response.result.failoverSafe === true && !binding && !response.result.providerBinding,
517
+ humanAction: response.result.humanAction,
518
+ });
519
+ }
520
+ return failure('COMPUTER_CHATGPT_EXTENSION_BOOTSTRAP_INVALID', { failoverSafe: false });
379
521
  }
380
522
 
381
523
  async findBySubmittedMarker(marker: string, bootstrapKey?: string, betweenObservations?: () => Promise<void>): Promise<ComputerChatgptTargetResult[]> {
@@ -388,15 +530,68 @@ implements ComputerChatgptConversationTargetPort, ComputerChatgptExtensionBroker
388
530
  })));
389
531
  await betweenObservations?.();
390
532
  const results: ComputerChatgptTargetResult[] = [];
391
- const seen = new Set<string>();
533
+ type MarkerMatch = Extract<ComputerChatgptExtensionCommandResult, { kind: 'marker_matches' }>['matches'][number];
534
+ const matchesByConversation = new Map<string, Array<{ instanceId: string; match: MarkerMatch }>>();
392
535
  for (const { instanceId, response } of responses) {
393
536
  if (response.result?.kind !== 'marker_matches') continue;
394
537
  for (const match of response.result.matches) {
395
- if (seen.has(match.identity.conversationId)) continue;
396
- seen.add(match.identity.conversationId);
397
- const record = await this.bindAsync(match.identity, match.providerBinding, instanceId);
398
- results.push({ state: 'ready', target: this.target(match.identity, record, instanceId), observation: match.observation });
538
+ const entries = matchesByConversation.get(match.identity.conversationId) ?? [];
539
+ entries.push({ instanceId, match });
540
+ matchesByConversation.set(match.identity.conversationId, entries);
541
+ }
542
+ }
543
+ for (const matches of matchesByConversation.values()) {
544
+ const first = matches[0]!;
545
+ const existing = this.authority.findSurfaceByStableIdentity(this.controllerHome, stableIdentity(first.match.identity))?.providerBinding;
546
+ const samePhysicalBinding = (match: MarkerMatch, binding: NonNullable<typeof existing>, instanceId: string): boolean => {
547
+ const observed = match.providerBinding;
548
+ return Boolean(observed
549
+ && observed.providerId === binding.providerId
550
+ && observed.providerSessionId === binding.providerSessionId
551
+ && observed.browserProduct === binding.browserProduct
552
+ && observed.windowId === binding.windowId
553
+ && observed.tabId === binding.tabId
554
+ && observed.ownerToken === binding.ownerToken
555
+ && instanceId === binding.providerSessionId);
556
+ };
557
+ const existingMatch = existing?.providerId === PROVIDER_ID
558
+ ? matches.find(({ instanceId, match }) => samePhysicalBinding(match, existing, instanceId))
559
+ : undefined;
560
+ const bootstrap = bootstrapKey
561
+ ? this.authority.findSurfaceByStableIdentity(this.controllerHome, {
562
+ surfaceType: 'browser-tab', ownership: 'provider_owned',
563
+ resource: { namespace: 'chatgpt.bootstrap', key: bootstrapKey },
564
+ })
565
+ : undefined;
566
+ const pinned = bootstrap?.providerBinding;
567
+ const bootstrapMatch = pinned?.providerId === PROVIDER_ID
568
+ ? matches.find(({ instanceId, match }) => samePhysicalBinding(match, pinned, instanceId))
569
+ : undefined;
570
+ // A task-specific bootstrap pin outranks a conversation surface for the
571
+ // same conversationId. If that exact physical pin is absent, preserve the
572
+ // unknown outcome instead of letting another tab's marker become authority.
573
+ if (pinned && !bootstrapMatch) continue;
574
+ const candidate = pinned
575
+ ? bootstrapMatch
576
+ : existingMatch ?? (matches.length === 1 && first.match.providerBinding ? first : undefined);
577
+ const bootstrapMatchBinding = bootstrapMatch?.match.providerBinding;
578
+ const existingMatchesBootstrap = Boolean(existingMatch && bootstrapMatch && bootstrapMatchBinding
579
+ && existingMatch.instanceId === bootstrapMatch.instanceId
580
+ && samePhysicalBinding(existingMatch.match, bootstrapMatchBinding, existingMatch.instanceId));
581
+ // Resolve duplicate marker tabs only with an exact persisted physical pin.
582
+ // A bootstrap pin already owns its tab: return that target so the caller
583
+ // can promote it, instead of prematurely binding a second conversation
584
+ // Surface to the same tab and hitting COMPUTER_SURFACE_INDEX_CONFLICT.
585
+ if (!candidate) continue;
586
+ if (bootstrap && candidate === bootstrapMatch && !existingMatchesBootstrap) {
587
+ const metadata = bootstrap.compatibilityRecords.find((entry) => entry.namespace === 'chatgpt.target')?.value as Record<string, unknown> | undefined;
588
+ if (typeof metadata?.projectUrl !== 'string' || !metadata.projectUrl) continue;
589
+ const identity: ComputerChatgptBootstrapIdentity = { namespace: 'chatgpt.bootstrap', bootstrapKey: bootstrapKey!, projectUrl: metadata.projectUrl };
590
+ results.push({ state: 'ready', target: this.target(identity, bootstrap, candidate.instanceId), observation: candidate.match.observation });
591
+ continue;
399
592
  }
593
+ const record = await this.bindAsync(candidate.match.identity, candidate.match.providerBinding, candidate.instanceId);
594
+ results.push({ state: 'ready', target: this.target(candidate.match.identity, record, candidate.instanceId), observation: candidate.match.observation });
400
595
  }
401
596
  return results;
402
597
  }
@@ -417,6 +612,60 @@ implements ComputerChatgptConversationTargetPort, ComputerChatgptExtensionBroker
417
612
  return { state: 'ready', target: this.target(identity, record, selected.instanceId) };
418
613
  }
419
614
 
615
+ /** Existing Surface binding is the only retirement authority. A claimed
616
+ * Chrome close with missing acknowledgement is outcome-unknown: retain the
617
+ * exact attachment so the same owner can reconcile/retry, never make it
618
+ * eligible for another physical owner by clearing the index prematurely. */
619
+ private retirePhysicalBinding(record: ComputerSurfaceTarget, identity?: ComputerChatgptTargetIdentity): Promise<boolean> {
620
+ const existing = this.retiring.get(record.targetId);
621
+ if (existing) return existing;
622
+ const pending = this.retirePhysicalBindingOnce(record, identity);
623
+ this.retiring.set(record.targetId, pending);
624
+ const release = (): void => {
625
+ if (this.retiring.get(record.targetId) === pending) this.retiring.delete(record.targetId);
626
+ };
627
+ void pending.then(release, release);
628
+ return pending;
629
+ }
630
+
631
+ private async retirePhysicalBindingOnce(record: ComputerSurfaceTarget, identity?: ComputerChatgptTargetIdentity): Promise<boolean> {
632
+ const previous = record.providerBinding;
633
+ if (previous?.providerId !== PROVIDER_ID) return false;
634
+ const resource = record.stableIdentity.resource;
635
+ if (previous.ownerToken) {
636
+ const instanceId = providerInstanceId(previous.providerSessionId);
637
+ if (!identity || !instanceId || !this.providerIsFresh(instanceId)) return false;
638
+ if ([...this.pending.values()].some((entry) => 'identity' in entry.command
639
+ && entry.command.kind !== 'close'
640
+ && entry.command.identity.namespace === resource?.namespace
641
+ && (entry.command.identity.namespace === 'chatgpt.conversation'
642
+ ? entry.command.identity.conversationId === resource?.key
643
+ : entry.command.identity.namespace === 'chatgpt.bootstrap' && entry.command.identity.bootstrapKey === resource?.key))) return false;
644
+ let acknowledged = false;
645
+ try {
646
+ const response = await this.submit(instanceId, { kind: 'close', identity, providerBinding: previous });
647
+ acknowledged = response.result?.kind === 'closed';
648
+ } catch { return false; }
649
+ if (!acknowledged) return false;
650
+ }
651
+ // A legacy or user-attached tab has no Forge-create token: detach only,
652
+ // never turn a matching ChatGPT URL into permission for physical closure.
653
+ let cleared = false;
654
+ try {
655
+ await this.authority.withSurfaceLease(this.controllerHome, record.targetId, async (lease) => {
656
+ const binding = lease.current().providerBinding;
657
+ if (binding?.providerId !== previous.providerId
658
+ || binding.providerSessionId !== previous.providerSessionId
659
+ || binding.browserProduct !== previous.browserProduct
660
+ || binding.windowId !== previous.windowId || binding.tabId !== previous.tabId
661
+ || binding.ownerToken !== previous.ownerToken) return;
662
+ lease.clearBinding();
663
+ cleared = true;
664
+ });
665
+ } catch { return false; }
666
+ return cleared;
667
+ }
668
+
420
669
  async cleanup(activeResourceKeys: readonly string[]): Promise<void> {
421
670
  const active = new Set(activeResourceKeys);
422
671
  for (const record of this.authority.listAllSurfaces(this.controllerHome)) {
@@ -427,10 +676,8 @@ implements ComputerChatgptConversationTargetPort, ComputerChatgptExtensionBroker
427
676
  const identity: ComputerChatgptTargetIdentity | undefined = resource.namespace === 'chatgpt.conversation'
428
677
  ? (typeof compatibility?.canonicalUrl === 'string' ? { namespace: 'chatgpt.conversation', conversationId: resource.key, canonicalUrl: compatibility.canonicalUrl } : undefined)
429
678
  : (typeof compatibility?.projectUrl === 'string' ? { namespace: 'chatgpt.bootstrap', bootstrapKey: resource.key, projectUrl: compatibility.projectUrl } : undefined);
430
- const instanceId = providerInstanceId(record.providerBinding?.providerSessionId);
431
- if (identity && instanceId && this.providerIsFresh(instanceId)) await this.submit(instanceId, { kind: 'close', identity, providerBinding: record.providerBinding }).catch(() => undefined);
432
- await this.authority.withSurfaceLease(this.controllerHome, record.targetId, async (lease) => { lease.clearBinding(); }).catch(() => undefined);
433
- if (resource.namespace === 'chatgpt.bootstrap') this.authority.tombstoneSurface(this.controllerHome, record.targetId);
679
+ const cleared = await this.retirePhysicalBinding(record, identity);
680
+ if (cleared && resource.namespace === 'chatgpt.bootstrap') this.authority.tombstoneSurface(this.controllerHome, record.targetId);
434
681
  }
435
682
  }
436
683
 
@@ -444,9 +691,7 @@ implements ComputerChatgptConversationTargetPort, ComputerChatgptExtensionBroker
444
691
  : resource?.namespace === 'chatgpt.bootstrap' && typeof compatibility?.projectUrl === 'string'
445
692
  ? { namespace: 'chatgpt.bootstrap', bootstrapKey: resource.key, projectUrl: compatibility.projectUrl }
446
693
  : undefined;
447
- const instanceId = providerInstanceId(record.providerBinding?.providerSessionId);
448
- if (identity && instanceId && this.providerIsFresh(instanceId)) await this.submit(instanceId, { kind: 'close', identity }).catch(() => undefined);
449
- await this.authority.withSurfaceLease(this.controllerHome, targetId, async (lease) => { lease.clearBinding(); }).catch(() => undefined);
694
+ if (record.providerBinding) await this.retirePhysicalBinding(record, identity);
450
695
  }
451
696
 
452
697
  async close(): Promise<void> {