@openagt/dashboard 0.0.0-stage → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +125 -2
- package/dist/agent-id.d.ts +13 -0
- package/dist/agent-id.d.ts.map +1 -0
- package/dist/agent-id.js +20 -0
- package/dist/agent-id.js.map +1 -0
- package/dist/agent-locks.d.ts +3 -0
- package/dist/agent-locks.d.ts.map +1 -0
- package/dist/agent-locks.js +40 -0
- package/dist/agent-locks.js.map +1 -0
- package/dist/agent-view.d.ts +39 -0
- package/dist/agent-view.d.ts.map +1 -0
- package/dist/agent-view.js +45 -0
- package/dist/agent-view.js.map +1 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +11 -0
- package/dist/bin.js.map +1 -0
- package/dist/branch-name.d.ts +8 -0
- package/dist/branch-name.d.ts.map +1 -0
- package/dist/branch-name.js +20 -0
- package/dist/branch-name.js.map +1 -0
- package/dist/bridge-browser.d.ts +217 -0
- package/dist/bridge-browser.d.ts.map +1 -0
- package/dist/bridge-browser.js +520 -0
- package/dist/bridge-browser.js.map +1 -0
- package/dist/browser-stream.d.ts +20 -0
- package/dist/browser-stream.d.ts.map +1 -0
- package/dist/browser-stream.js +51 -0
- package/dist/browser-stream.js.map +1 -0
- package/dist/browser.d.ts +16 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +38 -0
- package/dist/browser.js.map +1 -0
- package/dist/built-in.d.ts +38 -0
- package/dist/built-in.d.ts.map +1 -0
- package/dist/built-in.js +119 -0
- package/dist/built-in.js.map +1 -0
- package/dist/chrome-extension/background.js +532 -0
- package/dist/chrome-extension/content.js +1475 -0
- package/dist/chrome-extension/driver-plan.js +41 -0
- package/dist/chrome-extension/fingerprint.js +43 -0
- package/dist/chrome-extension/manifest.json +31 -0
- package/dist/chrome-extension/options.html +48 -0
- package/dist/chrome-extension/options.js +122 -0
- package/dist/cli.d.ts +43 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +186 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +16 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +30 -0
- package/dist/client.js.map +1 -0
- package/dist/cloud-run-state.d.ts +27 -0
- package/dist/cloud-run-state.d.ts.map +1 -0
- package/dist/cloud-run-state.js +36 -0
- package/dist/cloud-run-state.js.map +1 -0
- package/dist/cloud-scratch-refs.d.ts +85 -0
- package/dist/cloud-scratch-refs.d.ts.map +1 -0
- package/dist/cloud-scratch-refs.js +275 -0
- package/dist/cloud-scratch-refs.js.map +1 -0
- package/dist/cloud-work.d.ts +66 -0
- package/dist/cloud-work.d.ts.map +1 -0
- package/dist/cloud-work.js +204 -0
- package/dist/cloud-work.js.map +1 -0
- package/dist/daemon-runtime.d.ts +42 -0
- package/dist/daemon-runtime.d.ts.map +1 -0
- package/dist/daemon-runtime.js +183 -0
- package/dist/daemon-runtime.js.map +1 -0
- package/dist/daemon-services.d.ts +37 -0
- package/dist/daemon-services.d.ts.map +1 -0
- package/dist/daemon-services.js +126 -0
- package/dist/daemon-services.js.map +1 -0
- package/dist/daemon-tick.d.ts +68 -0
- package/dist/daemon-tick.d.ts.map +1 -0
- package/dist/daemon-tick.js +97 -0
- package/dist/daemon-tick.js.map +1 -0
- package/dist/daemon.d.ts +65 -0
- package/dist/daemon.d.ts.map +1 -0
- package/dist/daemon.js +202 -0
- package/dist/daemon.js.map +1 -0
- package/dist/dashboard/activity.d.ts +41 -0
- package/dist/dashboard/activity.d.ts.map +1 -0
- package/dist/dashboard/activity.js +56 -0
- package/dist/dashboard/activity.js.map +1 -0
- package/dist/dashboard/agent-handoff.d.ts +244 -0
- package/dist/dashboard/agent-handoff.d.ts.map +1 -0
- package/dist/dashboard/agent-handoff.js +310 -0
- package/dist/dashboard/agent-handoff.js.map +1 -0
- package/dist/dashboard/bridge-endpoints.d.ts +158 -0
- package/dist/dashboard/bridge-endpoints.d.ts.map +1 -0
- package/dist/dashboard/bridge-endpoints.js +382 -0
- package/dist/dashboard/bridge-endpoints.js.map +1 -0
- package/dist/dashboard/bridge-question.d.ts +33 -0
- package/dist/dashboard/bridge-question.d.ts.map +1 -0
- package/dist/dashboard/bridge-question.js +20 -0
- package/dist/dashboard/bridge-question.js.map +1 -0
- package/dist/dashboard/bridge-sessions.d.ts +25 -0
- package/dist/dashboard/bridge-sessions.d.ts.map +1 -0
- package/dist/dashboard/bridge-sessions.js +46 -0
- package/dist/dashboard/bridge-sessions.js.map +1 -0
- package/dist/dashboard/bridge-starts.d.ts +61 -0
- package/dist/dashboard/bridge-starts.d.ts.map +1 -0
- package/dist/dashboard/bridge-starts.js +94 -0
- package/dist/dashboard/bridge-starts.js.map +1 -0
- package/dist/dashboard/bridge-store.d.ts +179 -0
- package/dist/dashboard/bridge-store.d.ts.map +1 -0
- package/dist/dashboard/bridge-store.js +262 -0
- package/dist/dashboard/bridge-store.js.map +1 -0
- package/dist/dashboard/bundle.d.ts +12 -0
- package/dist/dashboard/bundle.d.ts.map +1 -0
- package/dist/dashboard/bundle.js +22 -0
- package/dist/dashboard/bundle.js.map +1 -0
- package/dist/dashboard/cache.d.ts +47 -0
- package/dist/dashboard/cache.d.ts.map +1 -0
- package/dist/dashboard/cache.js +87 -0
- package/dist/dashboard/cache.js.map +1 -0
- package/dist/dashboard/content-type.d.ts +3 -0
- package/dist/dashboard/content-type.d.ts.map +1 -0
- package/dist/dashboard/content-type.js +31 -0
- package/dist/dashboard/content-type.js.map +1 -0
- package/dist/dashboard/dashboard.d.ts +38 -0
- package/dist/dashboard/dashboard.d.ts.map +1 -0
- package/dist/dashboard/dashboard.js +42 -0
- package/dist/dashboard/dashboard.js.map +1 -0
- package/dist/dashboard/docs.d.ts +39 -0
- package/dist/dashboard/docs.d.ts.map +1 -0
- package/dist/dashboard/docs.js +59 -0
- package/dist/dashboard/docs.js.map +1 -0
- package/dist/dashboard/git-status.d.ts +44 -0
- package/dist/dashboard/git-status.d.ts.map +1 -0
- package/dist/dashboard/git-status.js +37 -0
- package/dist/dashboard/git-status.js.map +1 -0
- package/dist/dashboard/http.d.ts +29 -0
- package/dist/dashboard/http.d.ts.map +1 -0
- package/dist/dashboard/http.js +74 -0
- package/dist/dashboard/http.js.map +1 -0
- package/dist/dashboard/index.d.ts +24 -0
- package/dist/dashboard/index.d.ts.map +1 -0
- package/dist/dashboard/index.js +23 -0
- package/dist/dashboard/index.js.map +1 -0
- package/dist/dashboard/interventions.d.ts +67 -0
- package/dist/dashboard/interventions.d.ts.map +1 -0
- package/dist/dashboard/interventions.js +169 -0
- package/dist/dashboard/interventions.js.map +1 -0
- package/dist/dashboard/keyed-watcher.d.ts +29 -0
- package/dist/dashboard/keyed-watcher.d.ts.map +1 -0
- package/dist/dashboard/keyed-watcher.js +42 -0
- package/dist/dashboard/keyed-watcher.js.map +1 -0
- package/dist/dashboard/keys.d.ts +31 -0
- package/dist/dashboard/keys.d.ts.map +1 -0
- package/dist/dashboard/keys.js +37 -0
- package/dist/dashboard/keys.js.map +1 -0
- package/dist/dashboard/models.d.ts +25 -0
- package/dist/dashboard/models.d.ts.map +1 -0
- package/dist/dashboard/models.js +30 -0
- package/dist/dashboard/models.js.map +1 -0
- package/dist/dashboard/module-host.d.ts +44 -0
- package/dist/dashboard/module-host.d.ts.map +1 -0
- package/dist/dashboard/module-host.js +91 -0
- package/dist/dashboard/module-host.js.map +1 -0
- package/dist/dashboard/module-serve.d.ts +15 -0
- package/dist/dashboard/module-serve.d.ts.map +1 -0
- package/dist/dashboard/module-serve.js +45 -0
- package/dist/dashboard/module-serve.js.map +1 -0
- package/dist/dashboard/open-in-app.d.ts +43 -0
- package/dist/dashboard/open-in-app.d.ts.map +1 -0
- package/dist/dashboard/open-in-app.js +98 -0
- package/dist/dashboard/open-in-app.js.map +1 -0
- package/dist/dashboard/open-questions.d.ts +45 -0
- package/dist/dashboard/open-questions.d.ts.map +1 -0
- package/dist/dashboard/open-questions.js +62 -0
- package/dist/dashboard/open-questions.js.map +1 -0
- package/dist/dashboard/overview.d.ts +78 -0
- package/dist/dashboard/overview.d.ts.map +1 -0
- package/dist/dashboard/overview.js +87 -0
- package/dist/dashboard/overview.js.map +1 -0
- package/dist/dashboard/projects.d.ts +101 -0
- package/dist/dashboard/projects.d.ts.map +1 -0
- package/dist/dashboard/projects.js +63 -0
- package/dist/dashboard/projects.js.map +1 -0
- package/dist/dashboard/pull-requests.d.ts +127 -0
- package/dist/dashboard/pull-requests.d.ts.map +1 -0
- package/dist/dashboard/pull-requests.js +137 -0
- package/dist/dashboard/pull-requests.js.map +1 -0
- package/dist/dashboard/queue.d.ts +17 -0
- package/dist/dashboard/queue.d.ts.map +1 -0
- package/dist/dashboard/queue.js +19 -0
- package/dist/dashboard/queue.js.map +1 -0
- package/dist/dashboard/quota.d.ts +53 -0
- package/dist/dashboard/quota.d.ts.map +1 -0
- package/dist/dashboard/quota.js +42 -0
- package/dist/dashboard/quota.js.map +1 -0
- package/dist/dashboard/relay-endpoints.d.ts +32 -0
- package/dist/dashboard/relay-endpoints.d.ts.map +1 -0
- package/dist/dashboard/relay-endpoints.js +115 -0
- package/dist/dashboard/relay-endpoints.js.map +1 -0
- package/dist/dashboard/remote-run.d.ts +95 -0
- package/dist/dashboard/remote-run.d.ts.map +1 -0
- package/dist/dashboard/remote-run.js +235 -0
- package/dist/dashboard/remote-run.js.map +1 -0
- package/dist/dashboard/rpc-serve.d.ts +106 -0
- package/dist/dashboard/rpc-serve.d.ts.map +1 -0
- package/dist/dashboard/rpc-serve.js +180 -0
- package/dist/dashboard/rpc-serve.js.map +1 -0
- package/dist/dashboard/run-inbox.d.ts +31 -0
- package/dist/dashboard/run-inbox.d.ts.map +1 -0
- package/dist/dashboard/run-inbox.js +51 -0
- package/dist/dashboard/run-inbox.js.map +1 -0
- package/dist/dashboard/server.d.ts +105 -0
- package/dist/dashboard/server.d.ts.map +1 -0
- package/dist/dashboard/server.js +231 -0
- package/dist/dashboard/server.js.map +1 -0
- package/dist/dashboard/static.d.ts +9 -0
- package/dist/dashboard/static.d.ts.map +1 -0
- package/dist/dashboard/static.js +54 -0
- package/dist/dashboard/static.js.map +1 -0
- package/dist/dashboard/types.d.ts +135 -0
- package/dist/dashboard/types.d.ts.map +1 -0
- package/dist/dashboard/types.js +2 -0
- package/dist/dashboard/types.js.map +1 -0
- package/dist/dashboard/web-start-endpoints.d.ts +32 -0
- package/dist/dashboard/web-start-endpoints.d.ts.map +1 -0
- package/dist/dashboard/web-start-endpoints.js +75 -0
- package/dist/dashboard/web-start-endpoints.js.map +1 -0
- package/dist/dashboard-bundle/assets/client-CbBRB9m4.js +9 -0
- package/dist/dashboard-bundle/assets/jsx-runtime-By8HlURe.js +1 -0
- package/dist/dashboard-bundle/assets/main-B98UOA3_.js +140 -0
- package/dist/dashboard-bundle/assets/main-ByRjbCiA.css +2 -0
- package/dist/dashboard-bundle/assets/module-v213KWfH.js +21 -0
- package/dist/dashboard-bundle/assets/react-CZI7_Jkm.js +1 -0
- package/dist/dashboard-bundle/assets/react-dom-cJyBB_A4.js +1 -0
- package/dist/dashboard-bundle/assets/rolldown-runtime-QTnfLwEv.js +1 -0
- package/dist/dashboard-bundle/host/module.js +1 -0
- package/dist/dashboard-bundle/host/react-dom-client.js +1 -0
- package/dist/dashboard-bundle/host/react-dom.js +1 -0
- package/dist/dashboard-bundle/host/react-jsx-runtime.js +1 -0
- package/dist/dashboard-bundle/host/react.js +1 -0
- package/dist/dashboard-bundle/index.html +42 -0
- package/dist/dashboard-bundle/logo-animated.svg +34 -0
- package/dist/dashboard-bundle/logo.svg +16 -0
- package/dist/dashboard-rpc/context.d.ts +56 -0
- package/dist/dashboard-rpc/context.d.ts.map +1 -0
- package/dist/dashboard-rpc/context.js +106 -0
- package/dist/dashboard-rpc/context.js.map +1 -0
- package/dist/dashboard-rpc/control.d.ts +124 -0
- package/dist/dashboard-rpc/control.d.ts.map +1 -0
- package/dist/dashboard-rpc/control.js +284 -0
- package/dist/dashboard-rpc/control.js.map +1 -0
- package/dist/dashboard-rpc/devices.d.ts +9 -0
- package/dist/dashboard-rpc/devices.d.ts.map +1 -0
- package/dist/dashboard-rpc/devices.js +12 -0
- package/dist/dashboard-rpc/devices.js.map +1 -0
- package/dist/dashboard-rpc/events-tail.d.ts +68 -0
- package/dist/dashboard-rpc/events-tail.d.ts.map +1 -0
- package/dist/dashboard-rpc/events-tail.js +257 -0
- package/dist/dashboard-rpc/events-tail.js.map +1 -0
- package/dist/dashboard-rpc/events.d.ts +39 -0
- package/dist/dashboard-rpc/events.d.ts.map +1 -0
- package/dist/dashboard-rpc/events.js +47 -0
- package/dist/dashboard-rpc/events.js.map +1 -0
- package/dist/dashboard-rpc/index.d.ts +29 -0
- package/dist/dashboard-rpc/index.d.ts.map +1 -0
- package/dist/dashboard-rpc/index.js +40 -0
- package/dist/dashboard-rpc/index.js.map +1 -0
- package/dist/dashboard-rpc/models.d.ts +4 -0
- package/dist/dashboard-rpc/models.d.ts.map +1 -0
- package/dist/dashboard-rpc/models.js +8 -0
- package/dist/dashboard-rpc/models.js.map +1 -0
- package/dist/dashboard-rpc/modules.d.ts +45 -0
- package/dist/dashboard-rpc/modules.d.ts.map +1 -0
- package/dist/dashboard-rpc/modules.js +79 -0
- package/dist/dashboard-rpc/modules.js.map +1 -0
- package/dist/dashboard-rpc/preferences.d.ts +41 -0
- package/dist/dashboard-rpc/preferences.d.ts.map +1 -0
- package/dist/dashboard-rpc/preferences.js +65 -0
- package/dist/dashboard-rpc/preferences.js.map +1 -0
- package/dist/dashboard-rpc/projects.d.ts +85 -0
- package/dist/dashboard-rpc/projects.d.ts.map +1 -0
- package/dist/dashboard-rpc/projects.js +129 -0
- package/dist/dashboard-rpc/projects.js.map +1 -0
- package/dist/dashboard-rpc/quota.d.ts +4 -0
- package/dist/dashboard-rpc/quota.d.ts.map +1 -0
- package/dist/dashboard-rpc/quota.js +15 -0
- package/dist/dashboard-rpc/quota.js.map +1 -0
- package/dist/dashboard-rpc/reads.d.ts +185 -0
- package/dist/dashboard-rpc/reads.d.ts.map +1 -0
- package/dist/dashboard-rpc/reads.js +370 -0
- package/dist/dashboard-rpc/reads.js.map +1 -0
- package/dist/dashboard-rpc/relay-agent.d.ts +9 -0
- package/dist/dashboard-rpc/relay-agent.d.ts.map +1 -0
- package/dist/dashboard-rpc/relay-agent.js +21 -0
- package/dist/dashboard-rpc/relay-agent.js.map +1 -0
- package/dist/dashboard-rpc/relay-dispatch.d.ts +9 -0
- package/dist/dashboard-rpc/relay-dispatch.d.ts.map +1 -0
- package/dist/dashboard-rpc/relay-dispatch.js +24 -0
- package/dist/dashboard-rpc/relay-dispatch.js.map +1 -0
- package/dist/dashboard-rpc/stream-forward.d.ts +17 -0
- package/dist/dashboard-rpc/stream-forward.d.ts.map +1 -0
- package/dist/dashboard-rpc/stream-forward.js +38 -0
- package/dist/dashboard-rpc/stream-forward.js.map +1 -0
- package/dist/dashboard-rpc/test-context.d.ts +23 -0
- package/dist/dashboard-rpc/test-context.d.ts.map +1 -0
- package/dist/dashboard-rpc/test-context.js +43 -0
- package/dist/dashboard-rpc/test-context.js.map +1 -0
- package/dist/driver-names.d.ts +22 -0
- package/dist/driver-names.d.ts.map +1 -0
- package/dist/driver-names.js +29 -0
- package/dist/driver-names.js.map +1 -0
- package/dist/error-message.d.ts +8 -0
- package/dist/error-message.d.ts.map +1 -0
- package/dist/error-message.js +10 -0
- package/dist/error-message.js.map +1 -0
- package/dist/event-stream.d.ts +33 -0
- package/dist/event-stream.d.ts.map +1 -0
- package/dist/event-stream.js +104 -0
- package/dist/event-stream.js.map +1 -0
- package/dist/events.d.ts +236 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +5 -0
- package/dist/events.js.map +1 -0
- package/dist/format-bytes.d.ts +3 -0
- package/dist/format-bytes.d.ts.map +1 -0
- package/dist/format-bytes.js +18 -0
- package/dist/format-bytes.js.map +1 -0
- package/dist/has-remote.d.ts +8 -0
- package/dist/has-remote.d.ts.map +1 -0
- package/dist/has-remote.js +10 -0
- package/dist/has-remote.js.map +1 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/install.d.ts +31 -0
- package/dist/install.d.ts.map +1 -0
- package/dist/install.js +54 -0
- package/dist/install.js.map +1 -0
- package/dist/jsonl-tail.d.ts +59 -0
- package/dist/jsonl-tail.d.ts.map +1 -0
- package/dist/jsonl-tail.js +152 -0
- package/dist/jsonl-tail.js.map +1 -0
- package/dist/loopback-host.d.ts +20 -0
- package/dist/loopback-host.d.ts.map +1 -0
- package/dist/loopback-host.js +31 -0
- package/dist/loopback-host.js.map +1 -0
- package/dist/module-server.d.ts +56 -0
- package/dist/module-types/dashboard/components/DiffStat.d.ts +6 -0
- package/dist/module-types/dashboard/components/DriverModelMenu.d.ts +23 -0
- package/dist/module-types/dashboard/components/LinkActions.d.ts +26 -0
- package/dist/module-types/dashboard/components/Markdown.d.ts +4 -0
- package/dist/module-types/dashboard/components/SettingsRows.d.ts +37 -0
- package/dist/module-types/dashboard/components/StartAgentButton.d.ts +39 -0
- package/dist/module-types/dashboard/components/ui/badge.d.ts +2 -0
- package/dist/module-types/dashboard/components/ui/button.d.ts +10 -0
- package/dist/module-types/dashboard/components/ui/card.d.ts +5 -0
- package/dist/module-types/dashboard/components/ui/checkbox.d.ts +7 -0
- package/dist/module-types/dashboard/components/ui/dropdown-menu.d.ts +16 -0
- package/dist/module-types/dashboard/components/ui/input.d.ts +2 -0
- package/dist/module-types/dashboard/components/ui/option-label.d.ts +4 -0
- package/dist/module-types/dashboard/components/ui/popover.d.ts +8 -0
- package/dist/module-types/dashboard/components/ui/scroll-area.d.ts +10 -0
- package/dist/module-types/dashboard/components/ui/separator.d.ts +3 -0
- package/dist/module-types/dashboard/components/ui/skeleton.d.ts +2 -0
- package/dist/module-types/dashboard/components/ui/slider.d.ts +9 -0
- package/dist/module-types/dashboard/components/ui/tooltip.d.ts +11 -0
- package/dist/module-types/dashboard/lib/agent-settings.d.ts +2 -0
- package/dist/module-types/dashboard/lib/draft-handoff.d.ts +15 -0
- package/dist/module-types/dashboard/lib/format-date.d.ts +51 -0
- package/dist/module-types/dashboard/lib/host-services.d.ts +12 -0
- package/dist/module-types/dashboard/lib/models.d.ts +24 -0
- package/dist/module-types/dashboard/lib/route.d.ts +38 -0
- package/dist/module-types/dashboard/lib/rpc.d.ts +31 -0
- package/dist/module-types/dashboard/lib/use-action.d.ts +25 -0
- package/dist/module-types/dashboard/lib/use-async.d.ts +45 -0
- package/dist/module-types/dashboard/lib/use-modules.d.ts +79 -0
- package/dist/module-types/dashboard/lib/utils.d.ts +3 -0
- package/dist/module-types/dashboard/module/index.d.ts +335 -0
- package/dist/module-types/dashboard/rpc/models.d.ts +2 -0
- package/dist/module-types/dashboard/rpc/modules.d.ts +5 -0
- package/dist/module-types/src/agent-id.d.ts +12 -0
- package/dist/module-types/src/agent-locks.d.ts +2 -0
- package/dist/module-types/src/agent-view.d.ts +38 -0
- package/dist/module-types/src/bridge-browser.d.ts +216 -0
- package/dist/module-types/src/browser-stream.d.ts +19 -0
- package/dist/module-types/src/browser.d.ts +15 -0
- package/dist/module-types/src/built-in.d.ts +37 -0
- package/dist/module-types/src/client.d.ts +15 -0
- package/dist/module-types/src/cloud-run-state.d.ts +26 -0
- package/dist/module-types/src/dashboard/activity.d.ts +40 -0
- package/dist/module-types/src/dashboard/agent-handoff.d.ts +243 -0
- package/dist/module-types/src/dashboard/bridge-endpoints.d.ts +157 -0
- package/dist/module-types/src/dashboard/bridge-question.d.ts +32 -0
- package/dist/module-types/src/dashboard/bridge-store.d.ts +178 -0
- package/dist/module-types/src/dashboard/cache.d.ts +46 -0
- package/dist/module-types/src/dashboard/content-type.d.ts +2 -0
- package/dist/module-types/src/dashboard/dashboard.d.ts +37 -0
- package/dist/module-types/src/dashboard/docs.d.ts +38 -0
- package/dist/module-types/src/dashboard/git-status.d.ts +43 -0
- package/dist/module-types/src/dashboard/http.d.ts +28 -0
- package/dist/module-types/src/dashboard/interventions.d.ts +66 -0
- package/dist/module-types/src/dashboard/keyed-watcher.d.ts +28 -0
- package/dist/module-types/src/dashboard/keys.d.ts +30 -0
- package/dist/module-types/src/dashboard/models.d.ts +24 -0
- package/dist/module-types/src/dashboard/module-host.d.ts +43 -0
- package/dist/module-types/src/dashboard/module-serve.d.ts +14 -0
- package/dist/module-types/src/dashboard/open-in-app.d.ts +42 -0
- package/dist/module-types/src/dashboard/open-questions.d.ts +44 -0
- package/dist/module-types/src/dashboard/overview.d.ts +77 -0
- package/dist/module-types/src/dashboard/projects.d.ts +100 -0
- package/dist/module-types/src/dashboard/pull-requests.d.ts +126 -0
- package/dist/module-types/src/dashboard/queue.d.ts +16 -0
- package/dist/module-types/src/dashboard/quota.d.ts +52 -0
- package/dist/module-types/src/dashboard/remote-run.d.ts +94 -0
- package/dist/module-types/src/dashboard/rpc-serve.d.ts +105 -0
- package/dist/module-types/src/dashboard/run-inbox.d.ts +30 -0
- package/dist/module-types/src/dashboard/types.d.ts +134 -0
- package/dist/module-types/src/dashboard-rpc/context.d.ts +55 -0
- package/dist/module-types/src/dashboard-rpc/control.d.ts +123 -0
- package/dist/module-types/src/dashboard-rpc/devices.d.ts +8 -0
- package/dist/module-types/src/dashboard-rpc/events-tail.d.ts +67 -0
- package/dist/module-types/src/dashboard-rpc/events.d.ts +38 -0
- package/dist/module-types/src/dashboard-rpc/index.d.ts +28 -0
- package/dist/module-types/src/dashboard-rpc/models.d.ts +3 -0
- package/dist/module-types/src/dashboard-rpc/modules.d.ts +44 -0
- package/dist/module-types/src/dashboard-rpc/preferences.d.ts +40 -0
- package/dist/module-types/src/dashboard-rpc/projects.d.ts +84 -0
- package/dist/module-types/src/dashboard-rpc/quota.d.ts +3 -0
- package/dist/module-types/src/dashboard-rpc/reads.d.ts +184 -0
- package/dist/module-types/src/dashboard-rpc/relay-agent.d.ts +8 -0
- package/dist/module-types/src/dashboard-rpc/stream-forward.d.ts +16 -0
- package/dist/module-types/src/driver-names.d.ts +21 -0
- package/dist/module-types/src/error-message.d.ts +7 -0
- package/dist/module-types/src/event-stream.d.ts +32 -0
- package/dist/module-types/src/events.d.ts +235 -0
- package/dist/module-types/src/format-bytes.d.ts +2 -0
- package/dist/module-types/src/has-remote.d.ts +7 -0
- package/dist/module-types/src/index.d.ts +41 -0
- package/dist/module-types/src/jsonl-tail.d.ts +58 -0
- package/dist/module-types/src/loopback-host.d.ts +19 -0
- package/dist/module-types/src/node-fs.d.ts +32 -0
- package/dist/module-types/src/open-choices.d.ts +14 -0
- package/dist/module-types/src/openagent-dir.d.ts +10 -0
- package/dist/module-types/src/openagent-gitignore.d.ts +11 -0
- package/dist/module-types/src/pick-directory.d.ts +29 -0
- package/dist/module-types/src/preference-defaults.d.ts +42 -0
- package/dist/module-types/src/project-commands.d.ts +23 -0
- package/dist/module-types/src/project-errors.d.ts +59 -0
- package/dist/module-types/src/project-hooks.d.ts +129 -0
- package/dist/module-types/src/project-modules.d.ts +49 -0
- package/dist/module-types/src/project-presets.d.ts +29 -0
- package/dist/module-types/src/project.d.ts +28 -0
- package/dist/module-types/src/publish-levels.d.ts +32 -0
- package/dist/module-types/src/quota-boundary.d.ts +64 -0
- package/dist/module-types/src/quota-poller.d.ts +82 -0
- package/dist/module-types/src/registry.d.ts +226 -0
- package/dist/module-types/src/store/agent-checkout.d.ts +53 -0
- package/dist/module-types/src/store/agent-store.d.ts +266 -0
- package/dist/module-types/src/store/branches.d.ts +163 -0
- package/dist/module-types/src/store/git-host.d.ts +113 -0
- package/dist/module-types/src/store/index.d.ts +9 -0
- package/dist/module-types/src/store/provided.d.ts +16 -0
- package/dist/module-types/src/store/queue.d.ts +45 -0
- package/dist/module-types/src/store/repository.d.ts +32 -0
- package/dist/module-types/src/store/run-record.d.ts +21 -0
- package/dist/module-types/src/store/runs.d.ts +107 -0
- package/dist/module-types/src/store/tickets.d.ts +72 -0
- package/dist/module-types/src/terminal.d.ts +3 -0
- package/dist/module-types/src/worktrees.d.ts +89 -0
- package/dist/module.css +50 -0
- package/dist/node-fs.d.ts +33 -0
- package/dist/node-fs.d.ts.map +1 -0
- package/dist/node-fs.js +57 -0
- package/dist/node-fs.js.map +1 -0
- package/dist/open-choices.d.ts +15 -0
- package/dist/open-choices.d.ts.map +1 -0
- package/dist/open-choices.js +31 -0
- package/dist/open-choices.js.map +1 -0
- package/dist/openagent-dir.d.ts +11 -0
- package/dist/openagent-dir.d.ts.map +1 -0
- package/dist/openagent-dir.js +11 -0
- package/dist/openagent-dir.js.map +1 -0
- package/dist/openagent-gitignore.d.ts +12 -0
- package/dist/openagent-gitignore.d.ts.map +1 -0
- package/dist/openagent-gitignore.js +19 -0
- package/dist/openagent-gitignore.js.map +1 -0
- package/dist/pick-directory.d.ts +30 -0
- package/dist/pick-directory.d.ts.map +1 -0
- package/dist/pick-directory.js +102 -0
- package/dist/pick-directory.js.map +1 -0
- package/dist/preference-defaults.d.ts +43 -0
- package/dist/preference-defaults.d.ts.map +1 -0
- package/dist/preference-defaults.js +34 -0
- package/dist/preference-defaults.js.map +1 -0
- package/dist/project-commands.d.ts +24 -0
- package/dist/project-commands.d.ts.map +1 -0
- package/dist/project-commands.js +57 -0
- package/dist/project-commands.js.map +1 -0
- package/dist/project-errors.d.ts +60 -0
- package/dist/project-errors.d.ts.map +1 -0
- package/dist/project-errors.js +51 -0
- package/dist/project-errors.js.map +1 -0
- package/dist/project-hooks.d.ts +130 -0
- package/dist/project-hooks.d.ts.map +1 -0
- package/dist/project-hooks.js +232 -0
- package/dist/project-hooks.js.map +1 -0
- package/dist/project-modules.d.ts +50 -0
- package/dist/project-modules.d.ts.map +1 -0
- package/dist/project-modules.js +110 -0
- package/dist/project-modules.js.map +1 -0
- package/dist/project-pass.d.ts +32 -0
- package/dist/project-pass.d.ts.map +1 -0
- package/dist/project-pass.js +45 -0
- package/dist/project-pass.js.map +1 -0
- package/dist/project-presets.d.ts +30 -0
- package/dist/project-presets.d.ts.map +1 -0
- package/dist/project-presets.js +74 -0
- package/dist/project-presets.js.map +1 -0
- package/dist/project.d.ts +29 -0
- package/dist/project.d.ts.map +1 -0
- package/dist/project.js +41 -0
- package/dist/project.js.map +1 -0
- package/dist/publish-levels.d.ts +33 -0
- package/dist/publish-levels.d.ts.map +1 -0
- package/dist/publish-levels.js +53 -0
- package/dist/publish-levels.js.map +1 -0
- package/dist/quota-boundary.d.ts +65 -0
- package/dist/quota-boundary.d.ts.map +1 -0
- package/dist/quota-boundary.js +135 -0
- package/dist/quota-boundary.js.map +1 -0
- package/dist/quota-poller.d.ts +83 -0
- package/dist/quota-poller.d.ts.map +1 -0
- package/dist/quota-poller.js +118 -0
- package/dist/quota-poller.js.map +1 -0
- package/dist/registry.d.ts +227 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +364 -0
- package/dist/registry.js.map +1 -0
- package/dist/remove-files.d.ts +24 -0
- package/dist/remove-files.d.ts.map +1 -0
- package/dist/remove-files.js +98 -0
- package/dist/remove-files.js.map +1 -0
- package/dist/request-path.d.ts +10 -0
- package/dist/request-path.d.ts.map +1 -0
- package/dist/request-path.js +16 -0
- package/dist/request-path.js.map +1 -0
- package/dist/store/agent-checkout.d.ts +54 -0
- package/dist/store/agent-checkout.d.ts.map +1 -0
- package/dist/store/agent-checkout.js +61 -0
- package/dist/store/agent-checkout.js.map +1 -0
- package/dist/store/agent-store.d.ts +267 -0
- package/dist/store/agent-store.d.ts.map +1 -0
- package/dist/store/agent-store.js +190 -0
- package/dist/store/agent-store.js.map +1 -0
- package/dist/store/branches.d.ts +164 -0
- package/dist/store/branches.d.ts.map +1 -0
- package/dist/store/branches.js +187 -0
- package/dist/store/branches.js.map +1 -0
- package/dist/store/git-host.d.ts +114 -0
- package/dist/store/git-host.d.ts.map +1 -0
- package/dist/store/git-host.js +103 -0
- package/dist/store/git-host.js.map +1 -0
- package/dist/store/index.d.ts +10 -0
- package/dist/store/index.d.ts.map +1 -0
- package/dist/store/index.js +10 -0
- package/dist/store/index.js.map +1 -0
- package/dist/store/provided.d.ts +17 -0
- package/dist/store/provided.d.ts.map +1 -0
- package/dist/store/provided.js +37 -0
- package/dist/store/provided.js.map +1 -0
- package/dist/store/queue.d.ts +46 -0
- package/dist/store/queue.d.ts.map +1 -0
- package/dist/store/queue.js +61 -0
- package/dist/store/queue.js.map +1 -0
- package/dist/store/repository.d.ts +33 -0
- package/dist/store/repository.d.ts.map +1 -0
- package/dist/store/repository.js +25 -0
- package/dist/store/repository.js.map +1 -0
- package/dist/store/run-record.d.ts +22 -0
- package/dist/store/run-record.d.ts.map +1 -0
- package/dist/store/run-record.js +108 -0
- package/dist/store/run-record.js.map +1 -0
- package/dist/store/runs.d.ts +108 -0
- package/dist/store/runs.d.ts.map +1 -0
- package/dist/store/runs.js +146 -0
- package/dist/store/runs.js.map +1 -0
- package/dist/store/test-branches.d.ts +18 -0
- package/dist/store/test-branches.d.ts.map +1 -0
- package/dist/store/test-branches.js +59 -0
- package/dist/store/test-branches.js.map +1 -0
- package/dist/store/test-runs.d.ts +15 -0
- package/dist/store/test-runs.d.ts.map +1 -0
- package/dist/store/test-runs.js +37 -0
- package/dist/store/test-runs.js.map +1 -0
- package/dist/store/tickets.d.ts +73 -0
- package/dist/store/tickets.d.ts.map +1 -0
- package/dist/store/tickets.js +67 -0
- package/dist/store/tickets.js.map +1 -0
- package/dist/terminal.d.ts +4 -0
- package/dist/terminal.d.ts.map +1 -0
- package/dist/terminal.js +97 -0
- package/dist/terminal.js.map +1 -0
- package/dist/update-check.d.ts +44 -0
- package/dist/update-check.d.ts.map +1 -0
- package/dist/update-check.js +71 -0
- package/dist/update-check.js.map +1 -0
- package/dist/worktrees.d.ts +90 -0
- package/dist/worktrees.d.ts.map +1 -0
- package/dist/worktrees.js +123 -0
- package/dist/worktrees.js.map +1 -0
- package/package.json +103 -3
|
@@ -0,0 +1,1475 @@
|
|
|
1
|
+
// The page half of the Claude web bridge (#1237): find the question a cloud session is parked
|
|
2
|
+
// on, and hand it to the service worker, which is the only part that talks to the daemon.
|
|
3
|
+
//
|
|
4
|
+
// The token and the fetch deliberately live in the worker, not here. A content script shares a
|
|
5
|
+
// tab with claude.ai, so nothing in this file should ever hold the secret that talks to a
|
|
6
|
+
// daemon, and a fetch from here would carry the page's origin into a CORS check the daemon
|
|
7
|
+
// refuses on purpose.
|
|
8
|
+
//
|
|
9
|
+
// Four rounds of a read-only spike shaped the extraction below, and each failure is worth
|
|
10
|
+
// keeping because each was a different obstacle:
|
|
11
|
+
// 1. the page has <code> blocks with no <pre>, so a `pre code` selector examined nothing
|
|
12
|
+
// 2. the message body sits behind shadow roots, so document.body.innerText never saw it
|
|
13
|
+
// 3. fixed prefixes guessed an indentation nobody promised, so brace matching replaced them
|
|
14
|
+
// 4. our own system prompt renders on the page, so its await-choices spec is a decoy
|
|
15
|
+
//
|
|
16
|
+
// `check.mjs` covers all four offline, in jsdom, with no browser and no live session.
|
|
17
|
+
|
|
18
|
+
const POLL_MS = 2000
|
|
19
|
+
// How many surveys this page has run; read by the offline harness (check.mjs) to prove one page
|
|
20
|
+
// change costs one survey (#1707).
|
|
21
|
+
let surveys = 0
|
|
22
|
+
|
|
23
|
+
// Every observer `watch` runs, so the script's own drawing can pause them (#1707): the corner
|
|
24
|
+
// panel and the Driver overlay live under the observed root, and without the pause each survey's
|
|
25
|
+
// redraw was the next survey's trigger — a full survey about four times a second, forever, on a
|
|
26
|
+
// page where nothing had changed.
|
|
27
|
+
const watchers = new Set()
|
|
28
|
+
const OBSERVED = { childList: true, subtree: true, characterData: true }
|
|
29
|
+
let drawing = 0
|
|
30
|
+
|
|
31
|
+
/** Run `draw`, which writes the script's own elements, without the observers taking it for a page change. */
|
|
32
|
+
function ownWrites(draw) {
|
|
33
|
+
if (drawing++ === 0) for (const observer of watchers) observer.disconnect()
|
|
34
|
+
try {
|
|
35
|
+
draw()
|
|
36
|
+
} finally {
|
|
37
|
+
if (--drawing === 0) for (const observer of watchers) observer.observe(document.documentElement, OBSERVED)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** The on-page panel's element id. */
|
|
41
|
+
const PANEL_ID = 'oa-bridge-panel'
|
|
42
|
+
const IS_TOP = window.top === window
|
|
43
|
+
|
|
44
|
+
/** The cloud session this page is showing, which is the key the daemon joins runs on. */
|
|
45
|
+
function sessionIdFromUrl() {
|
|
46
|
+
const match = /\/code\/(session_[A-Za-z0-9]+)/.exec(location.href)
|
|
47
|
+
return match ? match[1] : undefined
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Hand a found question to the service worker, which owns the token and the fetch.
|
|
52
|
+
*
|
|
53
|
+
* Guarded on `chrome` existing so the offline harness (check.mjs) can run this file in jsdom,
|
|
54
|
+
* where there is no extension runtime. Failures are swallowed: a page that cannot reach the
|
|
55
|
+
* daemon must still show its panel rather than throw inside a DOM observer.
|
|
56
|
+
*/
|
|
57
|
+
/** What the daemon last said about our report, so the panel can say why nothing arrived. */
|
|
58
|
+
let bridgeStatus = 'not sent yet'
|
|
59
|
+
|
|
60
|
+
function reportToDaemon(parsed) {
|
|
61
|
+
const sessionId = sessionIdFromUrl()
|
|
62
|
+
if (!sessionId) return
|
|
63
|
+
// The block's own shape (#1554): `multi`, and per option `default` and `stop`, travel with
|
|
64
|
+
// the labels so the dashboard renders the gate as a local one and a stopping pick is typed
|
|
65
|
+
// back as a hand-over rather than a continuation.
|
|
66
|
+
const question = {
|
|
67
|
+
sessionId,
|
|
68
|
+
title: String(parsed.title ?? '').slice(0, 500),
|
|
69
|
+
options: (parsed.options ?? [])
|
|
70
|
+
.map(o =>
|
|
71
|
+
typeof o === 'string'
|
|
72
|
+
? { label: o }
|
|
73
|
+
: {
|
|
74
|
+
label: String(o?.label ?? ''),
|
|
75
|
+
...(o?.detail ? { detail: String(o.detail).slice(0, 500) } : {}),
|
|
76
|
+
...(o?.default === true ? { default: true } : {}),
|
|
77
|
+
...(o?.stop === true ? { stop: true } : {}),
|
|
78
|
+
},
|
|
79
|
+
)
|
|
80
|
+
.filter(o => o.label)
|
|
81
|
+
.slice(0, 20),
|
|
82
|
+
...(parsed.recommended ? { recommended: String(parsed.recommended) } : {}),
|
|
83
|
+
...(parsed.multi === true ? { multi: true } : {}),
|
|
84
|
+
}
|
|
85
|
+
if (!question.title || !question.options.length) return
|
|
86
|
+
// Offline (check.mjs, no extension runtime): expose what would have been posted, so the
|
|
87
|
+
// harness can pin the shape the daemon receives, not just the panel's summary of it.
|
|
88
|
+
if (typeof chrome === 'undefined') {
|
|
89
|
+
window.__oaBridgeQuestion = question
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
if (!chrome.runtime?.sendMessage) return
|
|
93
|
+
try {
|
|
94
|
+
chrome.runtime.sendMessage({ type: 'oa-question', question }, reply => {
|
|
95
|
+
// Silence here was the first thing to go wrong live: the worker answered "no token set"
|
|
96
|
+
// and nobody ever saw it, so a configured-looking extension simply did nothing.
|
|
97
|
+
if (chrome.runtime.lastError) bridgeStatus = chrome.runtime.lastError.message ?? 'worker unreachable'
|
|
98
|
+
else if (!reply) bridgeStatus = 'no reply from the worker'
|
|
99
|
+
else if (reply.ok) bridgeStatus = reply.skipped ? `sent (${reply.skipped})` : 'sent'
|
|
100
|
+
else bridgeStatus = reply.error ?? 'failed'
|
|
101
|
+
})
|
|
102
|
+
} catch (err) {
|
|
103
|
+
bridgeStatus = String(err?.message ?? err)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* What we last sent per session and position, so an unchanged message is not re-posted on every
|
|
109
|
+
* mutation. Keyed by session too, because the Driver tab moves between sessions inside one page
|
|
110
|
+
* (#1332) and a position alone would collide across them.
|
|
111
|
+
*/
|
|
112
|
+
const sentEvents = new Map()
|
|
113
|
+
|
|
114
|
+
/** What happened to the last transcript report, for the panel. */
|
|
115
|
+
let transcriptStatus = 'not sent yet'
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Where claude.ai marks the conversation's turns (#1225).
|
|
119
|
+
*
|
|
120
|
+
* The page renders no `<article>` elements — the session's transcript is a `role="feed"` of
|
|
121
|
+
* `transcript-row` elements, one per turn, each naming its position (`data-index`) and its kind
|
|
122
|
+
* (`data-perf-row`: `human`, `assistant`, or a `marker` such as "Resumed session"). The list is
|
|
123
|
+
* virtual, so a long session only keeps its tail in the DOM; the position is what lets the daemon
|
|
124
|
+
* keep one copy per turn regardless of which turns happen to be rendered.
|
|
125
|
+
*/
|
|
126
|
+
const TURN_ROW = '[data-testid="transcript-row"]'
|
|
127
|
+
/** Which row kinds are conversation; every other kind (markers) is left out of the mirror. */
|
|
128
|
+
const TURN_ROLES = { human: 'user', assistant: 'agent' }
|
|
129
|
+
|
|
130
|
+
function turnRows() {
|
|
131
|
+
return deepQueryAll(TURN_ROW)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The opening message: the run's own prompt, which quotes our whole protocol (#1568). */
|
|
135
|
+
function openingMessage() {
|
|
136
|
+
return turnRows().find(row => row.getAttribute('data-index') === '0')
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The transcript, one entry per conversation turn (#1225).
|
|
141
|
+
*
|
|
142
|
+
* Each turn carries up to 8000 characters from its start. Only the opening turn — the run's
|
|
143
|
+
* prompt — is ever that long; a reply that is still streaming is sent as it grows and replaced
|
|
144
|
+
* in place, since the daemon keys entries by position.
|
|
145
|
+
*/
|
|
146
|
+
function transcript() {
|
|
147
|
+
return turnRows().flatMap(row => {
|
|
148
|
+
const role = TURN_ROLES[row.getAttribute('data-perf-row')]
|
|
149
|
+
const seq = Number(row.getAttribute('data-index'))
|
|
150
|
+
if (!role || !Number.isInteger(seq) || seq < 0) return []
|
|
151
|
+
const text = cleanText(row.innerText ?? row.textContent ?? '')
|
|
152
|
+
return text ? [{ seq, role, text: text.slice(0, 8000) }] : []
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Private-use codepoints are icon-font glyphs, which carry no meaning outside their font. */
|
|
157
|
+
const ICON_GLYPHS = /[\uE000-\uF8FF]/g
|
|
158
|
+
|
|
159
|
+
/** A turn's text as prose: no icon glyphs, no blank lines, no leading or trailing whitespace. */
|
|
160
|
+
function cleanText(text) {
|
|
161
|
+
return text
|
|
162
|
+
.replace(ICON_GLYPHS, '')
|
|
163
|
+
.split('\n')
|
|
164
|
+
.map(line => line.trim())
|
|
165
|
+
.filter(Boolean)
|
|
166
|
+
.join('\n')
|
|
167
|
+
.trim()
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Tell the daemon what this injected script is and what it just saw.
|
|
172
|
+
*
|
|
173
|
+
* Diagnosis kept requiring a screenshot of the panel, so every wrong guess cost a round trip
|
|
174
|
+
* through a person. This says it directly: which version is running in the page, and what the
|
|
175
|
+
* last scrape found.
|
|
176
|
+
*/
|
|
177
|
+
function sayHello(sessionId, note) {
|
|
178
|
+
if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) return
|
|
179
|
+
const version = chrome.runtime.getManifest?.().version ?? '?'
|
|
180
|
+
try {
|
|
181
|
+
chrome.runtime.sendMessage({ type: 'oa-hello', sessionId, version, note }, reply => {
|
|
182
|
+
// The worker's reply says whether this tab is the Driver (#1332): the overlay goes up
|
|
183
|
+
// the moment the page loads, not when the first cycle reaches it.
|
|
184
|
+
if (!chrome.runtime.lastError && reply?.driver) setDriver(true)
|
|
185
|
+
})
|
|
186
|
+
} catch {
|
|
187
|
+
// Nothing to do; the next pass tries again.
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Post whatever changed since the last look. Only what changed: the observer fires on every DOM
|
|
193
|
+
* mutation and a session's transcript is mostly stable, so sending it all each time would be a
|
|
194
|
+
* few hundred kilobytes a second for no new information.
|
|
195
|
+
*/
|
|
196
|
+
function reportTranscript() {
|
|
197
|
+
const sessionId = sessionIdFromUrl()
|
|
198
|
+
if (!sessionId) return
|
|
199
|
+
const rows = turnRows()
|
|
200
|
+
const blocks = transcript()
|
|
201
|
+
// Offline (check.mjs, no extension runtime): expose what would have been posted.
|
|
202
|
+
if (typeof chrome === 'undefined') window.__oaBridgeTranscript = blocks
|
|
203
|
+
if (!rows.length) {
|
|
204
|
+
// Named, not guessed at: a page with no turn rows is a layout the mirror does not know, and
|
|
205
|
+
// saying so beats mirroring whatever text happens to be on screen.
|
|
206
|
+
transcriptStatus = 'no transcript rows found'
|
|
207
|
+
sayHello(sessionId, `rows 0, ${transcriptStatus}`)
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
const kinds = [...new Set(rows.map(row => row.getAttribute('data-perf-row') ?? '?'))].join(',')
|
|
211
|
+
sayHello(sessionId, `rows ${rows.length} (${kinds}), turns ${blocks.length}, ${transcriptStatus}`)
|
|
212
|
+
if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) return
|
|
213
|
+
const events = blocks.filter(event => sentEvents.get(`${sessionId}#${event.seq}`) !== event.text)
|
|
214
|
+
if (!events.length) {
|
|
215
|
+
transcriptStatus = `${blocks.length} turn(s), unchanged`
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
chrome.runtime.sendMessage({ type: 'oa-events', sessionId, events }, reply => {
|
|
220
|
+
if (chrome.runtime.lastError) {
|
|
221
|
+
transcriptStatus = chrome.runtime.lastError.message ?? 'worker unreachable'
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
// Only remember what the daemon actually took, so a rejected batch is retried.
|
|
225
|
+
if (reply?.ok) {
|
|
226
|
+
for (const event of events) sentEvents.set(`${sessionId}#${event.seq}`, event.text)
|
|
227
|
+
transcriptStatus = `sent ${events.length} of ${blocks.length} turn(s)`
|
|
228
|
+
} else {
|
|
229
|
+
transcriptStatus = reply?.error ?? 'failed'
|
|
230
|
+
}
|
|
231
|
+
})
|
|
232
|
+
} catch {
|
|
233
|
+
// Worker asleep or context invalidated; the next mutation retries.
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Every element matching a selector, including inside open shadow roots. */
|
|
238
|
+
function deepQueryAll(selector, root = document, seen = new Set()) {
|
|
239
|
+
const out = [...root.querySelectorAll(selector)]
|
|
240
|
+
for (const el of root.querySelectorAll('*')) {
|
|
241
|
+
if (!el.shadowRoot || seen.has(el)) continue
|
|
242
|
+
seen.add(el)
|
|
243
|
+
out.push(...deepQueryAll(selector, el.shadowRoot, seen))
|
|
244
|
+
}
|
|
245
|
+
return out
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** How many open shadow roots exist, which tells us whether that is where content hides. */
|
|
249
|
+
function countShadowRoots(root = document, seen = new Set()) {
|
|
250
|
+
let n = 0
|
|
251
|
+
for (const el of root.querySelectorAll('*')) {
|
|
252
|
+
if (!el.shadowRoot || seen.has(el)) continue
|
|
253
|
+
seen.add(el)
|
|
254
|
+
n += 1 + countShadowRoots(el.shadowRoot, seen)
|
|
255
|
+
}
|
|
256
|
+
return n
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Page text including shadow content, which plain body.innerText misses. */
|
|
260
|
+
function deepText() {
|
|
261
|
+
let text = document.body?.innerText ?? ''
|
|
262
|
+
for (const host of deepQueryAll('*')) {
|
|
263
|
+
if (host.shadowRoot) text += `\n${host.shadowRoot.textContent ?? ''}`
|
|
264
|
+
}
|
|
265
|
+
return text
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Our own await-protocol spec is part of the system prompt, and the page renders the prompt,
|
|
270
|
+
* so a session shows a JSON block with `options` before the agent has asked anything. Its
|
|
271
|
+
* values are the spec's placeholders. Round 3 extracted exactly that and reported
|
|
272
|
+
* `<the question>` as the question, which is the decoy working as designed.
|
|
273
|
+
*
|
|
274
|
+
* Two more decoys slipped that net on a live session (#1568). The browser-handoff example's
|
|
275
|
+
* title is placeholders *plus punctuation* — `<what…> (<the page…>)` — so the whole-string
|
|
276
|
+
* placeholder test missed it, and its labels are literal. And the approval example is literal
|
|
277
|
+
* end to end (`Ship this?`, Approve/Decline), which no placeholder heuristic can catch — it is
|
|
278
|
+
* matched verbatim instead, accepting that an agent asking the doc's exact sample question
|
|
279
|
+
* word-for-word loses (it was told to put a real title there).
|
|
280
|
+
*/
|
|
281
|
+
function isTemplate(parsed) {
|
|
282
|
+
const placeholder = v => typeof v === 'string' && /^<.*>$/.test(v.trim())
|
|
283
|
+
const title = typeof parsed.title === 'string' ? parsed.title : ''
|
|
284
|
+
if (placeholder(title)) return true
|
|
285
|
+
// Placeholders joined by punctuation only: strip every <…> group; a title with no letters
|
|
286
|
+
// left is the spec talking, while a real title mentioning `<SESSION_NAME>` keeps its words.
|
|
287
|
+
if (/<[^<>]+>/.test(title) && !/\p{L}/u.test(title.replace(/<[^<>]*>/g, ''))) return true
|
|
288
|
+
const labels = parsed.options.map(o => (typeof o === 'string' ? o : o?.label))
|
|
289
|
+
if (labels.length > 0 && labels.every(placeholder)) return true
|
|
290
|
+
const set = labels.filter(l => typeof l === 'string').join('|')
|
|
291
|
+
// The two literal examples the protocol ships, verbatim.
|
|
292
|
+
if (set === 'Handled it|Could not handle it') return true
|
|
293
|
+
if (title === 'Ship this?' && set === 'Approve|Decline') return true
|
|
294
|
+
return false
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Whether an element renders inside the transcript's opening message, crossing shadow
|
|
299
|
+
* boundaries on the way up. The page opens with the run's prompt — which quotes our whole
|
|
300
|
+
* protocol, examples included — so nothing inside that first message is ever a question the
|
|
301
|
+
* session asked (#1568). Roots are walked via their host so a block inside a shadowed
|
|
302
|
+
* highlighter still resolves to the turn row that carries it.
|
|
303
|
+
*/
|
|
304
|
+
function inFirstMessage(el, first) {
|
|
305
|
+
if (!first) return false
|
|
306
|
+
// A shadow tree's top parent is its ShadowRoot, whose `host` re-enters the outer tree.
|
|
307
|
+
for (let node = el; node; node = node.parentNode ?? node.host ?? null) {
|
|
308
|
+
if (node === first) return true
|
|
309
|
+
}
|
|
310
|
+
return false
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Every JSON object carrying `options` in the text, in the order they appear. */
|
|
314
|
+
function extractChoices(text, stats) {
|
|
315
|
+
const found = []
|
|
316
|
+
collectChoices(text, stats, found)
|
|
317
|
+
return found
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Brace-match every `options` object out of arbitrary surrounding text.
|
|
322
|
+
*
|
|
323
|
+
* Round 2 reached the text but could not read it, because matching on the fixed prefixes
|
|
324
|
+
* `{"title"` and `{\n "title"` guesses at an indentation nobody promised, and the block
|
|
325
|
+
* arrives with prose around it. Strings and escapes are tracked so a brace inside a label
|
|
326
|
+
* cannot close the object early.
|
|
327
|
+
*/
|
|
328
|
+
function collectChoices(text, stats, found) {
|
|
329
|
+
if (!text.includes('"options"')) return found
|
|
330
|
+
for (let i = 0; i < text.length; i++) {
|
|
331
|
+
if (text[i] !== '{') continue
|
|
332
|
+
let depth = 0
|
|
333
|
+
let inString = false
|
|
334
|
+
let escaped = false
|
|
335
|
+
for (let j = i; j < text.length; j++) {
|
|
336
|
+
const ch = text[j]
|
|
337
|
+
if (escaped) {
|
|
338
|
+
escaped = false
|
|
339
|
+
continue
|
|
340
|
+
}
|
|
341
|
+
if (ch === '\\') {
|
|
342
|
+
escaped = true
|
|
343
|
+
continue
|
|
344
|
+
}
|
|
345
|
+
if (ch === '"') {
|
|
346
|
+
inString = !inString
|
|
347
|
+
continue
|
|
348
|
+
}
|
|
349
|
+
if (inString) continue
|
|
350
|
+
if (ch === '{') depth++
|
|
351
|
+
else if (ch === '}') {
|
|
352
|
+
depth--
|
|
353
|
+
if (depth > 0) continue
|
|
354
|
+
const slice = text.slice(i, j + 1)
|
|
355
|
+
if (slice.includes('"options"')) {
|
|
356
|
+
if (stats) stats.candidates++
|
|
357
|
+
try {
|
|
358
|
+
const parsed = JSON.parse(slice)
|
|
359
|
+
if (Array.isArray(parsed.options)) {
|
|
360
|
+
found.push(parsed)
|
|
361
|
+
// Keep scanning past this object: the spec comes first and the real question
|
|
362
|
+
// after it, so the last one is the one that matters.
|
|
363
|
+
i = j
|
|
364
|
+
} else if (stats) stats.parsedWithoutOptions++
|
|
365
|
+
} catch (err) {
|
|
366
|
+
// Not this one, or still streaming. Record why, so a failing report says whether
|
|
367
|
+
// the block was never found or found and unparseable.
|
|
368
|
+
if (stats) {
|
|
369
|
+
stats.parseErrors++
|
|
370
|
+
stats.lastError = String(err.message ?? err).slice(0, 80)
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
break
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return found
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* The transcript row an element renders in, looking out through any shadow roots it sits behind:
|
|
383
|
+
* the rows are in the light DOM while a message's body is not.
|
|
384
|
+
*/
|
|
385
|
+
function rowOf(el) {
|
|
386
|
+
for (let node = el; node; node = node.getRootNode?.()?.host) {
|
|
387
|
+
const row = node.closest?.(TURN_ROW)
|
|
388
|
+
if (row) return row
|
|
389
|
+
}
|
|
390
|
+
return undefined
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Whether the user has already answered past that row: a human turn at a later position means
|
|
395
|
+
* the question was taken and the session moved on, however long the answered block stays on the
|
|
396
|
+
* page (#1703). A block not placed in any row is measured from the session's latest turn instead.
|
|
397
|
+
* With no positioned turns at all there is nothing to measure against, and the block stands.
|
|
398
|
+
*/
|
|
399
|
+
function answeredAfter(row) {
|
|
400
|
+
const rows = turnRows()
|
|
401
|
+
const position = r => Number(r.getAttribute('data-index'))
|
|
402
|
+
const kind = r => r.getAttribute('data-perf-row')
|
|
403
|
+
const at = row ? position(row) : Math.max(-1, ...rows.filter(r => kind(r) === 'assistant').map(position))
|
|
404
|
+
if (!Number.isInteger(at) || at < 0) return false
|
|
405
|
+
return rows.some(r => kind(r) === 'human' && position(r) > at)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Why the last survey reported no question when a block was on the page, for the panel. */
|
|
409
|
+
let choiceNote = ''
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* The question a parked run is waiting on. Our agents emit it as a fenced JSON block carrying
|
|
413
|
+
* `options`. Element-scoped first so the winner names where it was found, then the whole page
|
|
414
|
+
* as a fallback for a block split across elements by a syntax highlighter. A block the user has
|
|
415
|
+
* already answered — a human turn follows it — is not pending, whatever any store remembers: the
|
|
416
|
+
* daemon forgets what was answered when it restarts, and re-asking would type a second answer
|
|
417
|
+
* into a session that moved on (#1703).
|
|
418
|
+
*/
|
|
419
|
+
function findPendingChoice() {
|
|
420
|
+
choiceNote = ''
|
|
421
|
+
// `code` on its own: the page has <code> blocks with no <pre> wrapper (round 1), and they
|
|
422
|
+
// sit inside shadow roots (round 2). DOM order tracks transcript order, so the last real
|
|
423
|
+
// question wins over both the spec and any earlier answered one.
|
|
424
|
+
// The opening message is the run's own prompt, which quotes the whole protocol — examples
|
|
425
|
+
// included — so blocks inside it are documentation, never the session asking (#1568). A
|
|
426
|
+
// virtual list may have scrolled that row out of the DOM, in which case its decoys are gone
|
|
427
|
+
// with it and isTemplate alone stands.
|
|
428
|
+
const firstMessage = openingMessage()
|
|
429
|
+
const fromElements = []
|
|
430
|
+
for (const el of deepQueryAll('pre code, pre, code')) {
|
|
431
|
+
if (inFirstMessage(el, firstMessage)) continue
|
|
432
|
+
for (const parsed of extractChoices(el.textContent ?? '')) {
|
|
433
|
+
fromElements.push({ via: el.tagName.toLowerCase(), parsed, row: rowOf(el) })
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
// The page-text fallback reads everything, opening message included, so any block that also
|
|
437
|
+
// renders inside that first message is subtracted from it by value.
|
|
438
|
+
const inPrompt = new Set(extractChoices(firstMessage?.textContent ?? '').map(p => JSON.stringify(p)))
|
|
439
|
+
const candidates = fromElements.length
|
|
440
|
+
? fromElements
|
|
441
|
+
: extractChoices(deepText())
|
|
442
|
+
.filter(parsed => !inPrompt.has(JSON.stringify(parsed)))
|
|
443
|
+
.map(parsed => ({ via: 'page-text', parsed }))
|
|
444
|
+
const real = candidates.filter(c => !isTemplate(c.parsed))
|
|
445
|
+
const last = real.at(-1)
|
|
446
|
+
const answered = Boolean(last) && answeredAfter(last.row)
|
|
447
|
+
if (answered) choiceNote = 'already answered'
|
|
448
|
+
const found = answered ? undefined : last
|
|
449
|
+
if (found) reportToDaemon(found.parsed)
|
|
450
|
+
reportTranscript()
|
|
451
|
+
return found ?? undefined
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** The box an answer would be typed into, if this ever becomes two-way. */
|
|
455
|
+
function findComposer() {
|
|
456
|
+
const editable = deepQueryAll('div[contenteditable="true"]')[0]
|
|
457
|
+
if (editable) return { via: 'contenteditable', el: editable }
|
|
458
|
+
const textarea = deepQueryAll('textarea')[0]
|
|
459
|
+
if (textarea) return { via: 'textarea', el: textarea }
|
|
460
|
+
return undefined
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** What the last delivery attempt did, for the panel. */
|
|
464
|
+
let answerStatus = 'none delivered'
|
|
465
|
+
|
|
466
|
+
/** Put text in the composer. execCommand is what the live page accepts; jsdom has no such thing. */
|
|
467
|
+
function fillComposer(composer, text) {
|
|
468
|
+
if (composer.via === 'textarea') {
|
|
469
|
+
composer.el.value = text
|
|
470
|
+
composer.el.dispatchEvent(new Event('input', { bubbles: true }))
|
|
471
|
+
return true
|
|
472
|
+
}
|
|
473
|
+
composer.el.focus()
|
|
474
|
+
let inserted = false
|
|
475
|
+
try {
|
|
476
|
+
inserted = document.execCommand('insertText', false, text)
|
|
477
|
+
} catch {
|
|
478
|
+
inserted = false
|
|
479
|
+
}
|
|
480
|
+
if (!inserted) {
|
|
481
|
+
// The offline harness lands here; a real ProseMirror/Lexical editor may ignore it, which the
|
|
482
|
+
// submit check below would surface as an empty send.
|
|
483
|
+
composer.el.textContent = text
|
|
484
|
+
composer.el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: text }))
|
|
485
|
+
}
|
|
486
|
+
return true
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** The button that sends the composer, wherever the page keeps it this week. */
|
|
490
|
+
function findSendButton() {
|
|
491
|
+
const labelled = deepQueryAll('button[aria-label]').filter(
|
|
492
|
+
b => /send/i.test(b.getAttribute('aria-label') ?? '') && !b.disabled,
|
|
493
|
+
)
|
|
494
|
+
// The last one: pages render hidden or historical buttons above the live composer's.
|
|
495
|
+
return labelled.at(-1) ?? deepQueryAll('button[type="submit"]').at(-1)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Type the dashboard's pick into the composer and submit it (#1237).
|
|
500
|
+
*
|
|
501
|
+
* This is the one place the extension acts rather than observes, so it reports exactly what it
|
|
502
|
+
* did: which fill path, and whether the send was a button click or an Enter fallback. The text
|
|
503
|
+
* only ever comes from the daemon, which only accepts a label of the parked question, so what
|
|
504
|
+
* can be typed here is bounded by what the session itself offered.
|
|
505
|
+
*/
|
|
506
|
+
async function deliverAnswer(text) {
|
|
507
|
+
// Wait for the composer rather than failing on its absence: the first live delivery landed
|
|
508
|
+
// right after a tab revive, and claude.ai takes well over the naive 5 seconds to render, so
|
|
509
|
+
// "no composer on the page" mostly means "not yet". The harness shortens the wait.
|
|
510
|
+
const deadline = Date.now() + (window.__oaComposerWaitMs ?? 20000)
|
|
511
|
+
let composer = findComposer()
|
|
512
|
+
while (!composer && Date.now() < deadline) {
|
|
513
|
+
await new Promise(resolve => setTimeout(resolve, 500))
|
|
514
|
+
composer = findComposer()
|
|
515
|
+
}
|
|
516
|
+
if (!composer) {
|
|
517
|
+
answerStatus = 'failed: no composer on the page'
|
|
518
|
+
return { ok: false, note: 'no composer on the page' }
|
|
519
|
+
}
|
|
520
|
+
fillComposer(composer, text)
|
|
521
|
+
// Let the editor's own event handling settle before submitting, or the click sends nothing.
|
|
522
|
+
await new Promise(resolve => setTimeout(resolve, 400))
|
|
523
|
+
const button = findSendButton()
|
|
524
|
+
if (button) {
|
|
525
|
+
button.click()
|
|
526
|
+
answerStatus = `sent via ${composer.via} + button`
|
|
527
|
+
return { ok: true, note: `filled ${composer.via}, clicked send button` }
|
|
528
|
+
}
|
|
529
|
+
for (const type of ['keydown', 'keyup']) {
|
|
530
|
+
composer.el.dispatchEvent(
|
|
531
|
+
new KeyboardEvent(type, { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true }),
|
|
532
|
+
)
|
|
533
|
+
}
|
|
534
|
+
answerStatus = `sent via ${composer.via} + enter`
|
|
535
|
+
return { ok: true, note: `filled ${composer.via}, no send button, sent Enter` }
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ---------------------------------------------------------------------------
|
|
539
|
+
// Creating a session (#1328). A session created through this page's repo picker is repo-bound,
|
|
540
|
+
// and those are the ones that can push and open a pull request (#1320). So the daemon queues a repo, a branch and a prompt,
|
|
541
|
+
// and this drives the same controls a person would on the new-session page: the repo chip and
|
|
542
|
+
// its searchable list, the branch chip that appears beside it, the composer, send.
|
|
543
|
+
//
|
|
544
|
+
// Every selector here is a guess about someone else's UI, so this half reports rather than
|
|
545
|
+
// insists: `probeNewSession` describes what the page offers without touching it, and every
|
|
546
|
+
// failure below names the control it could not find.
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Visible text of a control, for matching chips and entries: the icon-font glyphs the page puts
|
|
550
|
+
* beside a label (a repo icon, a branch icon, the check on the chosen entry) are dropped, since
|
|
551
|
+
* "openagt/openagent" followed by a check glyph is the entry, not another entry.
|
|
552
|
+
*/
|
|
553
|
+
function controlText(el) {
|
|
554
|
+
return (el.textContent ?? '').replace(ICON_GLYPHS, '').replace(/\s+/g, ' ').trim()
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Whether a control is actually usable: rendered, enabled, not aria-hidden. The page keeps a
|
|
559
|
+
* closed picker's entries in the DOM, so an entry that is not visible is not on offer — a browser
|
|
560
|
+
* answers that exactly; the offline harness has no layout and answers nothing, which counts as yes.
|
|
561
|
+
*/
|
|
562
|
+
function usable(el) {
|
|
563
|
+
if (!el || el.disabled) return false
|
|
564
|
+
if (el.getAttribute?.('aria-hidden') === 'true') return false
|
|
565
|
+
if (typeof el.checkVisibility === 'function' && !el.checkVisibility()) return false
|
|
566
|
+
return true
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** Every button-like control on the page, most explicit picker shapes first. */
|
|
570
|
+
function menuTriggers() {
|
|
571
|
+
const out = []
|
|
572
|
+
const add = (el, via) => {
|
|
573
|
+
if (usable(el) && !out.some(entry => entry.el === el)) out.push({ el, via, text: controlText(el) })
|
|
574
|
+
}
|
|
575
|
+
for (const el of deepQueryAll('button[role="combobox"]')) add(el, 'combobox')
|
|
576
|
+
for (const el of deepQueryAll('button[aria-haspopup="listbox"], button[aria-haspopup="menu"], button[aria-haspopup="true"]')) add(el, 'haspopup')
|
|
577
|
+
for (const el of deepQueryAll('button[aria-expanded]')) add(el, 'expandable')
|
|
578
|
+
for (const el of deepQueryAll('button')) add(el, 'button')
|
|
579
|
+
return out
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* The composer's chips, in the order the page lays them out: the repository, then the branch,
|
|
584
|
+
* then "add another repository". Each is a combobox button; the branch chip exists only once a
|
|
585
|
+
* repository is chosen.
|
|
586
|
+
*/
|
|
587
|
+
function chips() {
|
|
588
|
+
return deepQueryAll('button[role="combobox"]').filter(usable).map(el => ({ el, via: 'combobox', text: controlText(el) }))
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** The entries an open picker currently offers — visible options only, the closed ones stay in the DOM. */
|
|
592
|
+
function menuEntries() {
|
|
593
|
+
return deepQueryAll('[role="option"]')
|
|
594
|
+
.filter(usable)
|
|
595
|
+
.map(el => ({ el, text: controlText(el) }))
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** The open picker's search box, if it has one. */
|
|
599
|
+
function pickerSearch() {
|
|
600
|
+
return deepQueryAll('input[role="combobox"], input[type="search"], input[placeholder*="search" i]').filter(usable).at(-1)
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** Type into a React-controlled input so the page sees the change: the native setter, then an input event. */
|
|
604
|
+
function typeInto(input, text) {
|
|
605
|
+
input.focus()
|
|
606
|
+
const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(input), 'value')?.set ?? Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
|
|
607
|
+
if (setter) setter.call(input, text)
|
|
608
|
+
else input.value = text
|
|
609
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** Wait for `read` to return something truthy, or give up. */
|
|
613
|
+
async function waitFor(read, timeoutMs, stepMs = 150) {
|
|
614
|
+
const deadline = Date.now() + timeoutMs
|
|
615
|
+
for (;;) {
|
|
616
|
+
const value = read()
|
|
617
|
+
if (value) return value
|
|
618
|
+
if (Date.now() >= deadline) return undefined
|
|
619
|
+
await new Promise(resolve => setTimeout(resolve, stepMs))
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** How long to wait for a picker to render its entries, and for the chips to settle. */
|
|
624
|
+
const MENU_WAIT_MS = 6000
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Open `trigger` and choose the entry whose text is one of `wanted` (lower-cased; the first is
|
|
628
|
+
* the full name, later ones aliases). Types the full name into the picker's search box when it
|
|
629
|
+
* has one, since a long list is filtered rather than scrolled. Never clicks the trigger itself
|
|
630
|
+
* or anything inside it as an entry. Returns a note either way.
|
|
631
|
+
*/
|
|
632
|
+
async function chooseFrom(trigger, wanted, hint) {
|
|
633
|
+
trigger.el.click()
|
|
634
|
+
const outside = e => e.el !== trigger.el && !trigger.el.contains(e.el)
|
|
635
|
+
const entries = () => menuEntries().filter(outside)
|
|
636
|
+
const find = () => {
|
|
637
|
+
const now = entries()
|
|
638
|
+
for (const w of wanted) {
|
|
639
|
+
const exact = now.find(e => e.text.toLowerCase() === w)
|
|
640
|
+
if (exact) return exact
|
|
641
|
+
}
|
|
642
|
+
return undefined
|
|
643
|
+
}
|
|
644
|
+
// The list is fetched after the picker opens: wait for it to hold something before filtering
|
|
645
|
+
// it, and filter only when it has a search box. A filter that finds nothing is cleared and the
|
|
646
|
+
// whole list scanned, in case the page's search matches differently than expected.
|
|
647
|
+
const search = await waitFor(pickerSearch, 1500, 100)
|
|
648
|
+
const loaded = await waitFor(() => entries().length > 0, MENU_WAIT_MS)
|
|
649
|
+
let hit = loaded ? find() : undefined
|
|
650
|
+
if (!hit && search) {
|
|
651
|
+
typeInto(search, wanted[0])
|
|
652
|
+
hit = await waitFor(find, 3000)
|
|
653
|
+
if (!hit) {
|
|
654
|
+
typeInto(search, '')
|
|
655
|
+
hit = await waitFor(find, 3000)
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
if (!hit) {
|
|
659
|
+
const seen = entries()
|
|
660
|
+
const diag = `search ${search ? `"${search.placeholder}"` : 'none'}, ${seen.length} visible of ${deepQueryAll('[role="option"]').length} options${seen.length ? `: ${seen.slice(0, 5).map(e => JSON.stringify(e.text.slice(0, 40))).join(', ')}` : ''}; chips ${JSON.stringify(chips().map(c => c.text))}`
|
|
661
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
|
662
|
+
return { ok: false, note: `${hint}: the list offered no "${wanted[0]}" (${diag})` }
|
|
663
|
+
}
|
|
664
|
+
hit.el.click()
|
|
665
|
+
return { ok: true, note: `${hint}: clicked "${hit.text}" via ${trigger.via}` }
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// The model (#1697). The page's model picker is a menu button beside the composer reading the
|
|
669
|
+
// current model — "Opus 5" — and its menu lists the models as radio items, each label beside a
|
|
670
|
+
// hidden keyboard-shortcut digit and, on the current one, a hidden check glyph; a "More models"
|
|
671
|
+
// item opens a submenu of older versions. A run started with a model must run on it, so when the
|
|
672
|
+
// request names one it is picked before anything is sent, and a menu that does not offer it, or
|
|
673
|
+
// a page with no picker, fails the creation rather than sending on whatever the page defaults to.
|
|
674
|
+
|
|
675
|
+
/** A model name as the dashboard sends it: `opus`, or a full label such as `Opus 4.8`. */
|
|
676
|
+
const MODEL_WORDS = /\b(fable|opus|sonnet|haiku)\b/i
|
|
677
|
+
|
|
678
|
+
/** The model picker's trigger: the menu button reading a model name. Not a chip, so the chips' order is untouched. */
|
|
679
|
+
function modelTrigger() {
|
|
680
|
+
return menuTriggers().find(t => t.el.getAttribute('aria-haspopup') === 'menu' && MODEL_WORDS.test(t.text))
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* A control's label without its decorations: the hidden shortcut digit and check glyph the page
|
|
685
|
+
* puts beside a model's name are not part of it, so "Sonnet 5" is read, not "Sonnet 53".
|
|
686
|
+
*/
|
|
687
|
+
function labelText(el) {
|
|
688
|
+
const parts = []
|
|
689
|
+
const walk = node => {
|
|
690
|
+
if (node.nodeType === Node.TEXT_NODE) parts.push(node.textContent)
|
|
691
|
+
else if (node.nodeType === Node.ELEMENT_NODE && node.getAttribute('aria-hidden') !== 'true' && node.tagName !== 'KBD') node.childNodes.forEach(walk)
|
|
692
|
+
}
|
|
693
|
+
walk(el)
|
|
694
|
+
return parts.join('').replace(ICON_GLYPHS, '').replace(/\s+/g, ' ').trim()
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** The models the open menu offers, by label. */
|
|
698
|
+
function modelEntries() {
|
|
699
|
+
return deepQueryAll('[role="menuitemradio"]')
|
|
700
|
+
.filter(usable)
|
|
701
|
+
.map(el => ({ el, text: labelText(el) }))
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/** Whether a label names the wanted model: the whole label, or the model's word in it — "opus" reads "Opus 5". */
|
|
705
|
+
function readsModel(text, wanted) {
|
|
706
|
+
const label = text.toLowerCase()
|
|
707
|
+
return label === wanted || new RegExp(`(^|[^a-z0-9])${wanted.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}($|[^a-z0-9])`).test(label)
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Open the model menu and click the entry for `wanted` (lower-cased): the exact label first, else
|
|
712
|
+
* the first entry carrying its word, which is the newest version, since the menu lists the current
|
|
713
|
+
* versions and keeps older ones behind "More models" — searched too when the menu itself offers
|
|
714
|
+
* nothing. Returns a note either way; nothing is clicked as an entry that is not one.
|
|
715
|
+
*/
|
|
716
|
+
async function chooseModel(trigger, wanted) {
|
|
717
|
+
trigger.el.click()
|
|
718
|
+
const entries = () => modelEntries().filter(e => e.el !== trigger.el && !trigger.el.contains(e.el))
|
|
719
|
+
const find = () => {
|
|
720
|
+
const now = entries()
|
|
721
|
+
return now.find(e => e.text.toLowerCase() === wanted) ?? now.find(e => readsModel(e.text, wanted))
|
|
722
|
+
}
|
|
723
|
+
const loaded = await waitFor(() => entries().length > 0, MENU_WAIT_MS)
|
|
724
|
+
let hit = loaded ? find() : undefined
|
|
725
|
+
let via = 'menu'
|
|
726
|
+
if (!hit) {
|
|
727
|
+
const more = deepQueryAll('[role="menuitem"][aria-haspopup="menu"]').filter(usable).find(el => /more models/i.test(labelText(el)))
|
|
728
|
+
if (more) {
|
|
729
|
+
const before = entries().length
|
|
730
|
+
more.click()
|
|
731
|
+
if (await waitFor(() => entries().length > before, MENU_WAIT_MS)) {
|
|
732
|
+
hit = find()
|
|
733
|
+
via = 'More models'
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
if (!hit) {
|
|
738
|
+
const seen = entries()
|
|
739
|
+
const diag = `${seen.length} entries${seen.length ? `: ${seen.slice(0, 12).map(e => JSON.stringify(e.text.slice(0, 40))).join(', ')}` : ''}`
|
|
740
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
|
741
|
+
return { ok: false, note: `model: the menu offered no "${wanted}" (${diag})` }
|
|
742
|
+
}
|
|
743
|
+
hit.el.click()
|
|
744
|
+
return { ok: true, note: `model: clicked "${hit.text}" via ${via}` }
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Describe the controls on this page without touching any of them. Text only, capped, so the
|
|
749
|
+
* report is safe to paste into an issue — the point is that a failed first run says what the
|
|
750
|
+
* page offers, so the selectors above can be aimed at the real markup.
|
|
751
|
+
*/
|
|
752
|
+
function probeNewSession() {
|
|
753
|
+
return {
|
|
754
|
+
url: location.href,
|
|
755
|
+
sessionId: sessionIdFromUrl() ?? null,
|
|
756
|
+
composer: findComposer()?.via ?? null,
|
|
757
|
+
sendButton: Boolean(findSendButton()),
|
|
758
|
+
model: modelTrigger()?.text ?? null,
|
|
759
|
+
chips: chips().map(c => c.text.slice(0, 80)),
|
|
760
|
+
triggers: menuTriggers()
|
|
761
|
+
.slice(0, 40)
|
|
762
|
+
.map(trigger => ({ via: trigger.via, text: trigger.text.slice(0, 80) }))
|
|
763
|
+
.filter(trigger => trigger.text),
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Drive the new-session flow: repo, branch, model, prompt, send, and report the session it became.
|
|
769
|
+
*
|
|
770
|
+
* The page remembers the last repository picked, so it may open already showing ours, another
|
|
771
|
+
* one, or none — the chips are waited for and read rather than assumed. The branch is checked,
|
|
772
|
+
* not assumed: the branch chip must read the requested ref before anything is sent, because a
|
|
773
|
+
* session opened on the wrong branch would push its work somewhere the run never looks. The
|
|
774
|
+
* model, when the request names one, is checked the same way (#1697): the picker must read it
|
|
775
|
+
* before anything is sent, since a session on the page's default model is the choice silently
|
|
776
|
+
* dropped. The session id is read from the URL, the one thing the page is guaranteed to tell us
|
|
777
|
+
* and exactly what the daemon joins runs on; a send that never becomes a session URL is a
|
|
778
|
+
* failure with a reason, never a silent success.
|
|
779
|
+
*/
|
|
780
|
+
async function createSession({ repo, branch, prompt, model }) {
|
|
781
|
+
const composer = await waitFor(findComposer, window.__oaComposerWaitMs ?? 20000)
|
|
782
|
+
if (!composer) return { ok: false, note: 'no composer on the new-session page' }
|
|
783
|
+
|
|
784
|
+
const bare = String(repo).split('/').pop() ?? repo
|
|
785
|
+
const repoWanted = [String(repo).toLowerCase(), bare.toLowerCase()]
|
|
786
|
+
const selectRepo = () => menuTriggers().find(t => /select repo|add repo|choose repo/i.test(t.text) || /select repo|choose repo/i.test(t.el.getAttribute('aria-label') ?? ''))
|
|
787
|
+
// The chips render a beat after the composer: wait for either a chip or the bare picker.
|
|
788
|
+
await waitFor(() => chips().some(c => c.text) || selectRepo(), MENU_WAIT_MS)
|
|
789
|
+
await new Promise(resolve => setTimeout(resolve, window.__oaMenuSettleMs ?? 800))
|
|
790
|
+
|
|
791
|
+
let repoNote
|
|
792
|
+
if (repoWanted.includes(chips()[0]?.text.toLowerCase())) {
|
|
793
|
+
repoNote = `repo already ${chips()[0].text}`
|
|
794
|
+
} else {
|
|
795
|
+
// A remembered other repository's chip is the picker; with nothing remembered, the page offers one.
|
|
796
|
+
const trigger = chips()[0]?.text ? chips()[0] : selectRepo()
|
|
797
|
+
if (!trigger) return { ok: false, note: `no repo picker on the page (${JSON.stringify(probeNewSession())})` }
|
|
798
|
+
let pick = await chooseFrom(trigger, repoWanted, 'repo')
|
|
799
|
+
if (!pick.ok) {
|
|
800
|
+
// The page may have finished loading its remembered repository under us: read again once.
|
|
801
|
+
await new Promise(resolve => setTimeout(resolve, 1500))
|
|
802
|
+
if (repoWanted.includes(chips()[0]?.text.toLowerCase())) pick = { ok: true, note: `repo already ${chips()[0].text} (after a late render)` }
|
|
803
|
+
else return pick
|
|
804
|
+
}
|
|
805
|
+
repoNote = pick.note
|
|
806
|
+
const chip = await waitFor(() => (repoWanted.includes(chips()[0]?.text.toLowerCase()) ? chips()[0] : undefined), MENU_WAIT_MS)
|
|
807
|
+
if (!chip) return { ok: false, note: `${repoNote}, but the repo chip does not read "${repo}" afterwards (chips: ${JSON.stringify(chips().map(c => c.text))})` }
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// The branch chip appears beside the repo chip once a repository is chosen, reading the default branch.
|
|
811
|
+
const branchWanted = [String(branch).toLowerCase()]
|
|
812
|
+
const readsBranch = () => (chips()[1]?.text.toLowerCase() === branchWanted[0] ? chips()[1] : undefined)
|
|
813
|
+
let branchChip = await waitFor(() => (chips()[1]?.text ? chips()[1] : undefined), MENU_WAIT_MS)
|
|
814
|
+
if (!branchChip) return { ok: false, note: `${repoNote}; no branch chip appeared beside the repo (chips: ${JSON.stringify(chips().map(c => c.text))})` }
|
|
815
|
+
let branchNote
|
|
816
|
+
if (readsBranch()) {
|
|
817
|
+
branchNote = `branch already ${branchChip.text}`
|
|
818
|
+
} else {
|
|
819
|
+
const pick = await chooseFrom(branchChip, branchWanted, 'branch')
|
|
820
|
+
if (!pick.ok) return { ok: false, note: `${repoNote}; ${pick.note}` }
|
|
821
|
+
branchNote = pick.note
|
|
822
|
+
branchChip = await waitFor(readsBranch, MENU_WAIT_MS)
|
|
823
|
+
if (!branchChip) return { ok: false, note: `${repoNote}; ${branchNote}, but the branch chip does not read "${branch}" — not sending (chips: ${JSON.stringify(chips().map(c => c.text))})` }
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// The model picker remembers the last pick too, so it is read before it is opened.
|
|
827
|
+
let modelNote
|
|
828
|
+
if (model) {
|
|
829
|
+
const wanted = String(model).trim().toLowerCase()
|
|
830
|
+
const readsWanted = () => {
|
|
831
|
+
const trigger = modelTrigger()
|
|
832
|
+
return trigger && readsModel(trigger.text, wanted) ? trigger : undefined
|
|
833
|
+
}
|
|
834
|
+
const trigger = await waitFor(modelTrigger, MENU_WAIT_MS)
|
|
835
|
+
if (!trigger) return { ok: false, note: `${repoNote}; ${branchNote}; no model picker on the page — not sending (${JSON.stringify(probeNewSession())})` }
|
|
836
|
+
if (readsWanted()) {
|
|
837
|
+
modelNote = `model already ${trigger.text}`
|
|
838
|
+
} else {
|
|
839
|
+
const pick = await chooseModel(trigger, wanted)
|
|
840
|
+
if (!pick.ok) return { ok: false, note: `${repoNote}; ${branchNote}; ${pick.note}` }
|
|
841
|
+
modelNote = pick.note
|
|
842
|
+
if (!(await waitFor(readsWanted, MENU_WAIT_MS))) {
|
|
843
|
+
return { ok: false, note: `${repoNote}; ${branchNote}; ${modelNote}, but the model picker does not read "${model}" afterwards — not sending (reads ${JSON.stringify(modelTrigger()?.text ?? null)})` }
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
fillComposer(composer, prompt)
|
|
849
|
+
await new Promise(resolve => setTimeout(resolve, 400))
|
|
850
|
+
const button = findSendButton()
|
|
851
|
+
if (button) button.click()
|
|
852
|
+
else {
|
|
853
|
+
for (const type of ['keydown', 'keyup']) {
|
|
854
|
+
composer.el.dispatchEvent(new KeyboardEvent(type, { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true }))
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
const sessionId = await waitFor(sessionIdFromUrl, window.__oaSessionWaitMs ?? 60000, 500)
|
|
859
|
+
const notes = [repoNote, branchNote, ...(modelNote ? [modelNote] : [])].join('; ')
|
|
860
|
+
if (!sessionId) return { ok: false, note: `sent, but the page never became a session URL (${notes})` }
|
|
861
|
+
return { ok: true, sessionId, note: `${notes}; sent via ${button ? 'button' : 'enter'}` }
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
// ---------------------------------------------------------------------------
|
|
866
|
+
// The Driver tab (#1332): one tab serving every watched session. The worker asks this page to
|
|
867
|
+
// read claude.ai's own session list — the status icon beside each session carries a text label —
|
|
868
|
+
// and then to visit the sessions it picked, navigating inside the app: clicking a session's row
|
|
869
|
+
// and the "New" link back, never a page load, so fifty sessions cost fifty clicks. A visited
|
|
870
|
+
// session goes through the same survey and mirror as any other page.
|
|
871
|
+
|
|
872
|
+
/** The list's labels, and the word each becomes on the bridge. Anything else is reported as unknown, with its label. */
|
|
873
|
+
const LIST_LABELS = new Map([
|
|
874
|
+
['Awaiting input', 'awaiting'],
|
|
875
|
+
['Unread response', 'unread'],
|
|
876
|
+
['Idle', 'idle'],
|
|
877
|
+
['Running', 'running'],
|
|
878
|
+
])
|
|
879
|
+
/**
|
|
880
|
+
* A pull request's label stands in for the status only with no status word on the row: merged or
|
|
881
|
+
* closed, the session's work landed; in any other state — "Open", "Draft" — the session went
|
|
882
|
+
* quiet and is idle (#1707). Only landed is spelled out: a state the bridge has not seen is
|
|
883
|
+
* still a pull request that has not landed.
|
|
884
|
+
*/
|
|
885
|
+
const PR_LABEL = /^#\d+ · /
|
|
886
|
+
const LANDED_LABEL = /^#\d+ · (Merged|Closed)\b/
|
|
887
|
+
const NAV_WAIT_MS = 15000
|
|
888
|
+
const ROWS_WAIT_MS = 20000
|
|
889
|
+
const SEND_WAIT_MS = 15000
|
|
890
|
+
const LIST_PAGE_WAIT_MS = 5000
|
|
891
|
+
const MAX_LIST_PAGES = 10
|
|
892
|
+
const LOG_LINES = 300
|
|
893
|
+
const OVERLAY_ID = 'oa-driver-overlay'
|
|
894
|
+
|
|
895
|
+
/** claude.ai's sign-in page, and the sign-out step that redirects to it: where a person must act. */
|
|
896
|
+
const signInPage = () => /^\/(login|logout)(\/|$)/.test(location.pathname)
|
|
897
|
+
|
|
898
|
+
/** Take the overlay down, if it is up. */
|
|
899
|
+
function removeOverlay() {
|
|
900
|
+
const overlay = document.getElementById(OVERLAY_ID)
|
|
901
|
+
if (overlay) ownWrites(() => overlay.remove())
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
let isDriver = false
|
|
905
|
+
const driverLines = []
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Whether a list read or a drive is running in this page. A second instruction meanwhile is
|
|
909
|
+
* refused as busy rather than run alongside it: two drives would navigate over each other. It
|
|
910
|
+
* happens when the worker that sent the first one ended mid-cycle — the page drives on regardless.
|
|
911
|
+
*/
|
|
912
|
+
let driving = false
|
|
913
|
+
|
|
914
|
+
async function whileFree(work) {
|
|
915
|
+
if (driving) return { ok: false, busy: true, note: 'the Driver is still busy with an earlier cycle in this page' }
|
|
916
|
+
driving = true
|
|
917
|
+
try {
|
|
918
|
+
return await work()
|
|
919
|
+
} finally {
|
|
920
|
+
driving = false
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function setDriver(on) {
|
|
925
|
+
if (!on || isDriver) return
|
|
926
|
+
isDriver = true
|
|
927
|
+
ensureOverlay()
|
|
928
|
+
driverLog(`driver armed on ${location.pathname}`)
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* One line of the cycle log: shown behind the overlay's "Show debug logs", and sent to the
|
|
933
|
+
* worker, which stays awake for as long as lines keep coming.
|
|
934
|
+
*/
|
|
935
|
+
function driverLog(line) {
|
|
936
|
+
const stamped = `${new Date().toISOString().slice(11, 19)} ${line}`
|
|
937
|
+
driverLines.push(stamped)
|
|
938
|
+
if (driverLines.length > LOG_LINES) driverLines.shift()
|
|
939
|
+
renderOverlay()
|
|
940
|
+
if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) return
|
|
941
|
+
try {
|
|
942
|
+
chrome.runtime.sendMessage({ type: 'oa-driver-log', line: stamped }, () => void chrome.runtime.lastError)
|
|
943
|
+
} catch {
|
|
944
|
+
// Dead context; the log still renders.
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/** What the list's status icon says about a session row. */
|
|
949
|
+
function statusOf(anchor) {
|
|
950
|
+
const labels = [...anchor.querySelectorAll('[aria-label]')]
|
|
951
|
+
.map(el => el.getAttribute('aria-label') ?? '')
|
|
952
|
+
.filter(label => label && !/^more options/i.test(label))
|
|
953
|
+
for (const label of labels) {
|
|
954
|
+
const known = LIST_LABELS.get(label)
|
|
955
|
+
if (known) return { status: known }
|
|
956
|
+
}
|
|
957
|
+
// Only with no status word on the row does its pull request stand in for one: a row can carry
|
|
958
|
+
// both, and "Awaiting input" beside a pull request is still a session stopped for its user.
|
|
959
|
+
if (labels.some(label => LANDED_LABEL.test(label))) return { status: 'landed' }
|
|
960
|
+
if (labels.some(label => PR_LABEL.test(label))) return { status: 'idle' }
|
|
961
|
+
return { status: 'unknown', label: (labels[0] ?? '').slice(0, 80) }
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/** The session rows the list shows, by session id: each row links to its session, so the id is in its address. */
|
|
965
|
+
function sessionListRows() {
|
|
966
|
+
const rows = new Map()
|
|
967
|
+
for (const anchor of deepQueryAll('a[href*="/code/session_"]')) {
|
|
968
|
+
const id = /\/code\/(session_[A-Za-z0-9]+)/.exec(anchor.getAttribute('href') ?? '')?.[1]
|
|
969
|
+
if (!id) continue
|
|
970
|
+
const row = { anchor, ...statusOf(anchor) }
|
|
971
|
+
// The first link wins unless a later one carries a status and it did not: a link to the
|
|
972
|
+
// session elsewhere on the page is not its list row.
|
|
973
|
+
if (!rows.has(id) || (rows.get(id).status === 'unknown' && row.status !== 'unknown')) rows.set(id, row)
|
|
974
|
+
}
|
|
975
|
+
return rows
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* How far up from a "Show N more" button the container holding session rows may be for the
|
|
980
|
+
* button to count as the list's own. The list's button sits one level from its rows; the
|
|
981
|
+
* middle panel's sits twenty-eight from them, at the app's root (measured 2026-08-25).
|
|
982
|
+
*/
|
|
983
|
+
const LIST_BUTTON_REACH = 3
|
|
984
|
+
|
|
985
|
+
/** The list's "Show N more" button, if the list has more to show. The middle panel's is not it. */
|
|
986
|
+
function showMoreButton() {
|
|
987
|
+
const candidates = deepQueryAll('button').filter(b => /^show \d+ more$/i.test((b.textContent ?? '').trim()) && usable(b))
|
|
988
|
+
for (const button of candidates) {
|
|
989
|
+
let el = button.parentElement
|
|
990
|
+
for (let distance = 0; el && distance <= LIST_BUTTON_REACH; el = el.parentElement, distance++) {
|
|
991
|
+
if (el.querySelector('a[href*="/code/session_"]')) return button
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
return undefined
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* Read the list for these sessions, paging it until each is found or the list ends. The list is
|
|
999
|
+
* fetched after the page reports loaded, so it is waited for before it is read; a page with no
|
|
1000
|
+
* session rows at all is not a list — signed out, or not on the sessions page — and is named
|
|
1001
|
+
* rather than read as every session missing.
|
|
1002
|
+
*/
|
|
1003
|
+
async function readSessionList(ids) {
|
|
1004
|
+
if (!(await waitFor(() => sessionListRows().size > 0, ROWS_WAIT_MS))) {
|
|
1005
|
+
driverLog(`list: no session rows on ${location.pathname}`)
|
|
1006
|
+
return { ok: false, note: `no session rows on ${location.pathname}` }
|
|
1007
|
+
}
|
|
1008
|
+
let rows = sessionListRows()
|
|
1009
|
+
let pages = 0
|
|
1010
|
+
while (ids.some(id => !rows.has(id)) && pages < MAX_LIST_PAGES) {
|
|
1011
|
+
const more = showMoreButton()
|
|
1012
|
+
if (!more) break
|
|
1013
|
+
const before = rows.size
|
|
1014
|
+
more.click()
|
|
1015
|
+
pages++
|
|
1016
|
+
const grew = await waitFor(() => sessionListRows().size > before, LIST_PAGE_WAIT_MS)
|
|
1017
|
+
rows = sessionListRows()
|
|
1018
|
+
if (!grew) break
|
|
1019
|
+
}
|
|
1020
|
+
const statuses = ids.map(id => {
|
|
1021
|
+
const row = rows.get(id)
|
|
1022
|
+
return row ? { sessionId: id, status: row.status, ...(row.label ? { label: row.label } : {}) } : { sessionId: id, status: 'missing' }
|
|
1023
|
+
})
|
|
1024
|
+
driverLog(
|
|
1025
|
+
`list: ${rows.size} rows${pages ? ` after ${pages} more page(s)` : ''}; ${statuses.map(s => `${s.sessionId.slice(8, 14)}=${s.status}${s.label ? `(${s.label})` : ''}`).join(' ')}`,
|
|
1026
|
+
)
|
|
1027
|
+
return { ok: true, statuses, listed: rows.size, pages }
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
const atHome = () => location.pathname.replace(/\/+$/, '') === '/code'
|
|
1031
|
+
|
|
1032
|
+
/** Back to the session list through the app's own "New" link: no page load. */
|
|
1033
|
+
async function goHome() {
|
|
1034
|
+
if (atHome()) return true
|
|
1035
|
+
const link = deepQueryAll('a[href="/code"]').find(usable)
|
|
1036
|
+
if (!link) return false
|
|
1037
|
+
link.click()
|
|
1038
|
+
return Boolean(await waitFor(atHome, NAV_WAIT_MS))
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
function composerText() {
|
|
1042
|
+
const composer = findComposer()
|
|
1043
|
+
if (!composer) return ''
|
|
1044
|
+
return (composer.via === 'textarea' ? composer.el.value : composer.el.textContent) ?? ''
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* Visit one session: click its row, wait for its transcript, run the survey (question and mirror
|
|
1049
|
+
* reach the daemon as from any page), and type the queued answer if there is one — counted as
|
|
1050
|
+
* sent only once the composer is empty again and the transcript gained the turn.
|
|
1051
|
+
*/
|
|
1052
|
+
async function visitSession(visit) {
|
|
1053
|
+
const row = sessionListRows().get(visit.id)
|
|
1054
|
+
if (!row) return { id: visit.id, ok: false, note: 'not on the list' }
|
|
1055
|
+
driverLog(`visit ${visit.id} (${visit.status})`)
|
|
1056
|
+
const stale = new Set(turnRows())
|
|
1057
|
+
row.anchor.click()
|
|
1058
|
+
if (!(await waitFor(() => sessionIdFromUrl() === visit.id, NAV_WAIT_MS))) return { id: visit.id, ok: false, note: `the page did not become ${visit.id}` }
|
|
1059
|
+
// The address changes before the page does: wait for turn rows that are not the previous
|
|
1060
|
+
// session's, so what is surveyed and typed into is this session's page.
|
|
1061
|
+
await waitFor(() => turnRows().some(r => !stale.has(r)), ROWS_WAIT_MS)
|
|
1062
|
+
await new Promise(resolve => setTimeout(resolve, window.__oaSettleMs ?? 1500))
|
|
1063
|
+
const found = findPendingChoice()
|
|
1064
|
+
const result = { id: visit.id, ok: true, rows: turnRows().length, ...(found ? { question: String(found.parsed.title ?? '').slice(0, 100) } : {}) }
|
|
1065
|
+
if (visit.answer) {
|
|
1066
|
+
const before = new Set(turnRows())
|
|
1067
|
+
let outcome = await deliverAnswer(visit.answer.text)
|
|
1068
|
+
if (outcome.ok) {
|
|
1069
|
+
// Taken once the composer is empty again and a turn row exists that did not before — a
|
|
1070
|
+
// new row, not a higher count, since a long transcript keeps only its tail rendered.
|
|
1071
|
+
const taken = await waitFor(() => !composerText().trim() && turnRows().some(r => !before.has(r)), window.__oaSendWaitMs ?? SEND_WAIT_MS)
|
|
1072
|
+
if (!taken) {
|
|
1073
|
+
outcome = {
|
|
1074
|
+
ok: false,
|
|
1075
|
+
note: `${outcome.note}, but the page did not take the send: composer ${composerText().trim() ? 'still holds text' : 'empty'}, rows ${before.size} -> ${turnRows().length}`,
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
reportTranscript()
|
|
1080
|
+
result.delivered = { sessionId: visit.id, id: visit.answer.id, ok: outcome.ok, ...(outcome.note ? { note: outcome.note } : {}) }
|
|
1081
|
+
}
|
|
1082
|
+
return result
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* One cycle: the session the daemon asked for first, if any — a run is waiting on it, and the
|
|
1087
|
+
* new-session page is where a cycle starts — then the visits, then home.
|
|
1088
|
+
*/
|
|
1089
|
+
async function drive({ visits = [], start }) {
|
|
1090
|
+
setDriver(true)
|
|
1091
|
+
driverLog(`cycle: ${start ? 'one session to create, ' : ''}${visits.length} visit(s)`)
|
|
1092
|
+
let started
|
|
1093
|
+
if (start) {
|
|
1094
|
+
const wasHome = atHome()
|
|
1095
|
+
const there = wasHome || (await goHome())
|
|
1096
|
+
// A navigation home swaps the page under us a beat after the address changes.
|
|
1097
|
+
if (there && !wasHome) await new Promise(resolve => setTimeout(resolve, window.__oaSettleMs ?? 1500))
|
|
1098
|
+
started = there ? await createSession(start) : { ok: false, note: 'could not reach the new-session page' }
|
|
1099
|
+
driverLog(`create: ${started.ok ? started.sessionId : started.note}`)
|
|
1100
|
+
}
|
|
1101
|
+
const visited = []
|
|
1102
|
+
const delivered = []
|
|
1103
|
+
for (const visit of visits) {
|
|
1104
|
+
const result = await visitSession(visit)
|
|
1105
|
+
visited.push(result)
|
|
1106
|
+
if (result.delivered) delivered.push(result.delivered)
|
|
1107
|
+
driverLog(
|
|
1108
|
+
` ${visit.id}: ${
|
|
1109
|
+
result.ok
|
|
1110
|
+
? `${result.rows} rows${result.question ? `, question "${result.question}"` : ''}${result.delivered ? `, answer ${result.delivered.ok ? 'sent' : `failed: ${result.delivered.note}`}` : ''}`
|
|
1111
|
+
: result.note
|
|
1112
|
+
}`,
|
|
1113
|
+
)
|
|
1114
|
+
}
|
|
1115
|
+
const home = await goHome()
|
|
1116
|
+
if (!home) driverLog('could not get back to the session list')
|
|
1117
|
+
return { ok: true, visited, delivered, ...(started ? { started } : {}), ...(home ? {} : { note: 'could not get back to the session list' }) }
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* The full-page overlay: this tab is not for people. Appended beside the app's root rather than
|
|
1122
|
+
* inside it, so an in-app navigation leaves it alone; the watcher re-appends it if anything does
|
|
1123
|
+
* remove it. Typing goes through the composer's own value and the send button's click, not
|
|
1124
|
+
* through the pointer, so covering the composer costs nothing.
|
|
1125
|
+
*/
|
|
1126
|
+
function ensureOverlay() {
|
|
1127
|
+
if (!isDriver) return
|
|
1128
|
+
// The sign-in pages are the one place a person has to act in this tab (the daemon's bridge
|
|
1129
|
+
// browser is signed in exactly there, once), so the overlay stands aside on them and comes back
|
|
1130
|
+
// with the next page.
|
|
1131
|
+
if (signInPage()) return removeOverlay()
|
|
1132
|
+
if (document.getElementById(OVERLAY_ID)) return
|
|
1133
|
+
const overlay = document.createElement('div')
|
|
1134
|
+
overlay.id = OVERLAY_ID
|
|
1135
|
+
overlay.style.cssText = [
|
|
1136
|
+
'position:fixed', 'inset:0', 'z-index:2147483647', 'background:#0f1218', 'color:#dbdbdb',
|
|
1137
|
+
'font:15px/1.5 ui-sans-serif,system-ui,sans-serif', 'display:flex', 'flex-direction:column',
|
|
1138
|
+
'align-items:center', 'justify-content:center', 'padding:32px', 'box-sizing:border-box', 'overflow:auto',
|
|
1139
|
+
].join(';')
|
|
1140
|
+
const heading = document.createElement('h1')
|
|
1141
|
+
heading.textContent = 'OpenAgent Driver'
|
|
1142
|
+
heading.style.cssText = 'font-size:32px;margin:0 0 12px;color:#a7c080;font-weight:600'
|
|
1143
|
+
const phrase = document.createElement('p')
|
|
1144
|
+
phrase.textContent =
|
|
1145
|
+
'OpenAgent is using this tab to watch your Claude Code sessions and to type your answers into them. Use another tab for claude.ai. Closing this tab pauses the bridge; the extension’s options page reopens it.'
|
|
1146
|
+
phrase.style.cssText = 'max-width:560px;text-align:center;margin:0 0 16px;color:#aab2c0'
|
|
1147
|
+
const status = document.createElement('div')
|
|
1148
|
+
status.className = 'oa-driver-status'
|
|
1149
|
+
status.style.cssText = 'font:12px/1.45 ui-monospace,monospace;color:#7f8797;text-align:center;max-width:800px'
|
|
1150
|
+
const details = document.createElement('details')
|
|
1151
|
+
details.style.cssText = 'margin-top:20px;width:min(900px,100%)'
|
|
1152
|
+
const summary = document.createElement('summary')
|
|
1153
|
+
summary.textContent = 'Show debug logs'
|
|
1154
|
+
summary.style.cssText = 'cursor:pointer;color:#7f8797;text-align:center'
|
|
1155
|
+
const log = document.createElement('pre')
|
|
1156
|
+
log.className = 'oa-driver-log'
|
|
1157
|
+
log.style.cssText = 'max-height:50vh;overflow:auto;background:#1f2430;padding:12px;border-radius:8px;font:12px/1.45 ui-monospace,monospace;white-space:pre-wrap;margin:8px 0 0'
|
|
1158
|
+
details.append(summary, log)
|
|
1159
|
+
overlay.append(heading, phrase, status, details)
|
|
1160
|
+
ownWrites(() => document.documentElement.appendChild(overlay))
|
|
1161
|
+
renderOverlay()
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function renderOverlay() {
|
|
1165
|
+
if (signInPage()) return removeOverlay()
|
|
1166
|
+
const overlay = document.getElementById(OVERLAY_ID)
|
|
1167
|
+
if (!overlay) return
|
|
1168
|
+
const version = typeof chrome !== 'undefined' && chrome.runtime?.getManifest ? chrome.runtime.getManifest().version : '?'
|
|
1169
|
+
ownWrites(() => {
|
|
1170
|
+
overlay.querySelector('.oa-driver-status').textContent = `bridge v${version} · ${location.pathname} · ${turnRows().length} turn rows · question ${bridgeStatus} · transcript ${transcriptStatus}`
|
|
1171
|
+
overlay.querySelector('.oa-driver-log').textContent = driverLines.join('\n')
|
|
1172
|
+
})
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// The worker drives the top frame only: the list and the composer live there, and a child frame
|
|
1176
|
+
// acting too would click and submit twice.
|
|
1177
|
+
if (IS_TOP && typeof chrome !== 'undefined' && chrome.runtime?.onMessage) {
|
|
1178
|
+
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
|
1179
|
+
if (message?.type === 'oa-read-list' && Array.isArray(message.ids)) {
|
|
1180
|
+
setDriver(true)
|
|
1181
|
+
void whileFree(() => readSessionList(message.ids.filter(id => typeof id === 'string')))
|
|
1182
|
+
.then(sendResponse)
|
|
1183
|
+
.catch(err => sendResponse({ ok: false, note: String(err?.message ?? err) }))
|
|
1184
|
+
return true
|
|
1185
|
+
}
|
|
1186
|
+
if (message?.type !== 'oa-drive') return false
|
|
1187
|
+
void whileFree(() => drive({ visits: Array.isArray(message.visits) ? message.visits : [], start: message.start }))
|
|
1188
|
+
.then(sendResponse)
|
|
1189
|
+
.catch(err => sendResponse({ ok: false, note: String(err?.message ?? err) }))
|
|
1190
|
+
return true
|
|
1191
|
+
})
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// The offline harness (check.mjs) runs this file in jsdom, where there is no extension runtime
|
|
1195
|
+
// to hand answers through. Exposed only there: on a real page `chrome` exists and nothing is
|
|
1196
|
+
// added to the window claude.ai can see.
|
|
1197
|
+
if (typeof chrome === 'undefined') {
|
|
1198
|
+
window.__oaBridgeDeliverAnswer = deliverAnswer
|
|
1199
|
+
window.__oaBridgeCreateSession = createSession
|
|
1200
|
+
window.__oaBridgeProbeNewSession = probeNewSession
|
|
1201
|
+
window.__oaBridgeReadSessionList = ids => whileFree(() => readSessionList(ids))
|
|
1202
|
+
window.__oaBridgeDrive = args => whileFree(() => drive(args))
|
|
1203
|
+
window.__oaBridgeSurveys = () => surveys
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/**
|
|
1207
|
+
* What the page looks like when extraction fails. Structure and lengths only, never message
|
|
1208
|
+
* text, so the report stays safe to paste into a public issue.
|
|
1209
|
+
*/
|
|
1210
|
+
function diagnostics() {
|
|
1211
|
+
const codes = deepQueryAll('pre, code')
|
|
1212
|
+
.map(el => (el.textContent ?? '').trim())
|
|
1213
|
+
.filter(t => t.includes('"options"') || t.startsWith('{'))
|
|
1214
|
+
const frames = [...document.querySelectorAll('iframe')]
|
|
1215
|
+
let reachableFrames = 0
|
|
1216
|
+
for (const f of frames) {
|
|
1217
|
+
try {
|
|
1218
|
+
if (f.contentDocument?.body) reachableFrames++
|
|
1219
|
+
} catch {
|
|
1220
|
+
// Cross origin, which is itself the answer.
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
const deep = deepText()
|
|
1224
|
+
// Did the brace matcher find object-shaped candidates, and did any fail to parse? This is
|
|
1225
|
+
// what separates "never found it" from "found it and could not read it".
|
|
1226
|
+
const stats = { candidates: 0, parseErrors: 0, parsedWithoutOptions: 0, lastError: '' }
|
|
1227
|
+
const all = extractChoices(deep, stats)
|
|
1228
|
+
return {
|
|
1229
|
+
frame: IS_TOP ? 'top' : 'child',
|
|
1230
|
+
jsonCandidates: stats.candidates,
|
|
1231
|
+
jsonObjects: all.length,
|
|
1232
|
+
jsonTemplates: all.filter(isTemplate).length,
|
|
1233
|
+
jsonParseErrors: stats.parseErrors,
|
|
1234
|
+
jsonLastError: stats.lastError,
|
|
1235
|
+
pre: deepQueryAll('pre').length,
|
|
1236
|
+
code: deepQueryAll('code').length,
|
|
1237
|
+
jsonishBlocks: codes.length,
|
|
1238
|
+
shadowRoots: countShadowRoots(),
|
|
1239
|
+
iframes: frames.length,
|
|
1240
|
+
reachableFrames,
|
|
1241
|
+
contentEditables: deepQueryAll('div[contenteditable="true"]').length,
|
|
1242
|
+
textareas: deepQueryAll('textarea').length,
|
|
1243
|
+
// The decisive pair: does the block exist in the top body, and does it exist once
|
|
1244
|
+
// shadow content is included?
|
|
1245
|
+
optionsInBodyText: (document.body?.innerText ?? '').includes('"options"'),
|
|
1246
|
+
optionsInDeepText: deep.includes('"options"'),
|
|
1247
|
+
deepTextLength: deep.length,
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
function survey() {
|
|
1252
|
+
surveys++
|
|
1253
|
+
const choice = findPendingChoice()
|
|
1254
|
+
const composer = findComposer()
|
|
1255
|
+
return {
|
|
1256
|
+
url: location.href,
|
|
1257
|
+
choiceFound: Boolean(choice),
|
|
1258
|
+
choiceVia: choice?.via,
|
|
1259
|
+
choiceTitle: choice?.parsed?.title,
|
|
1260
|
+
choiceOptions: (choice?.parsed?.options ?? []).map(o => o.label ?? o),
|
|
1261
|
+
composerFound: Boolean(composer),
|
|
1262
|
+
composerVia: composer?.via,
|
|
1263
|
+
diagnostics: diagnostics(),
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
// A child frame has no panel of its own: it reports upward, so the top frame's report says
|
|
1268
|
+
// whether the content was in a frame and which one. This is what settles the iframe question.
|
|
1269
|
+
if (!IS_TOP) {
|
|
1270
|
+
const send = () => {
|
|
1271
|
+
const s = survey()
|
|
1272
|
+
if (s.choiceFound || s.diagnostics.jsonishBlocks || s.diagnostics.optionsInDeepText) {
|
|
1273
|
+
parent.postMessage({ __oaBridge: s }, '*')
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
send()
|
|
1277
|
+
watch(send)
|
|
1278
|
+
} else {
|
|
1279
|
+
let fromFrame
|
|
1280
|
+
window.addEventListener('message', e => {
|
|
1281
|
+
if (e.data && typeof e.data === 'object' && e.data.__oaBridge) fromFrame = e.data.__oaBridge
|
|
1282
|
+
})
|
|
1283
|
+
|
|
1284
|
+
const panel = document.createElement('div')
|
|
1285
|
+
panel.id = PANEL_ID
|
|
1286
|
+
panel.style.cssText = [
|
|
1287
|
+
'position:fixed', 'bottom:16px', 'right:16px', 'z-index:2147483647',
|
|
1288
|
+
'width:360px', 'max-height:70vh', 'overflow:auto',
|
|
1289
|
+
'background:#1f2430', 'color:#dbdbdb', 'font:12px/1.45 ui-monospace,monospace',
|
|
1290
|
+
'border:1px solid #3a4150', 'border-radius:8px', 'padding:10px 12px',
|
|
1291
|
+
'box-shadow:0 6px 24px rgba(0,0,0,.35)',
|
|
1292
|
+
].join(';')
|
|
1293
|
+
document.documentElement.appendChild(panel)
|
|
1294
|
+
|
|
1295
|
+
let latest = survey()
|
|
1296
|
+
// Announce the page once whatever it is: on the session list there is no session to report,
|
|
1297
|
+
// but the worker's reply is what tells the Driver tab it is the Driver (#1332).
|
|
1298
|
+
sayHello(sessionIdFromUrl(), `page loaded on ${location.pathname}`)
|
|
1299
|
+
|
|
1300
|
+
// Whether the panel is folded down to a compact OA tab. Remembered in chrome.storage rather than
|
|
1301
|
+
// the page's localStorage: the preference should survive a reload, and nothing the extension
|
|
1302
|
+
// keeps should be readable by the page it watches. Restored asynchronously, so the panel can
|
|
1303
|
+
// open expanded for a beat before the remembered fold lands.
|
|
1304
|
+
let collapsed = false
|
|
1305
|
+
if (typeof chrome !== 'undefined' && chrome.storage?.local) {
|
|
1306
|
+
try {
|
|
1307
|
+
chrome.storage.local.get('panelCollapsed', stored => {
|
|
1308
|
+
if (!chrome.runtime.lastError && stored?.panelCollapsed) {
|
|
1309
|
+
collapsed = true
|
|
1310
|
+
render()
|
|
1311
|
+
}
|
|
1312
|
+
})
|
|
1313
|
+
} catch {
|
|
1314
|
+
// Dead context; the panel starts expanded, and reloading the tab is the fix anyway.
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
const button = (label, onClick) => {
|
|
1319
|
+
const b = document.createElement('button')
|
|
1320
|
+
b.textContent = label
|
|
1321
|
+
b.style.cssText = 'margin-top:8px;width:100%;padding:5px;cursor:pointer;background:#2d3441;color:#dbdbdb;border:1px solid #3a4150;border-radius:5px;font:inherit'
|
|
1322
|
+
b.addEventListener('click', onClick)
|
|
1323
|
+
return b
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
// Drawn under `ownWrites`: the redraw must not read as a page change (#1707).
|
|
1327
|
+
const render = () => ownWrites(() => {
|
|
1328
|
+
const top = survey()
|
|
1329
|
+
// A child frame's find wins: it means the content lives there, which is the finding.
|
|
1330
|
+
latest = { ...top, fromFrame: fromFrame ?? null }
|
|
1331
|
+
// The Driver tab (#1332) shows its overlay instead of the corner panel; the survey above
|
|
1332
|
+
// still ran, so its sessions are read and mirrored like any other page's.
|
|
1333
|
+
if (isDriver) {
|
|
1334
|
+
panel.style.display = 'none'
|
|
1335
|
+
ensureOverlay()
|
|
1336
|
+
renderOverlay()
|
|
1337
|
+
return
|
|
1338
|
+
}
|
|
1339
|
+
panel.style.display = ''
|
|
1340
|
+
const winner = top.choiceFound ? top : fromFrame?.choiceFound ? fromFrame : top
|
|
1341
|
+
const d = top.diagnostics
|
|
1342
|
+
panel.innerHTML = ''
|
|
1343
|
+
panel.style.width = collapsed ? 'auto' : '360px'
|
|
1344
|
+
panel.style.padding = collapsed ? '6px 8px' : '10px 12px'
|
|
1345
|
+
const head = document.createElement('div')
|
|
1346
|
+
head.style.cssText = `display:flex;align-items:center;gap:8px${collapsed ? '' : ';margin-bottom:6px'}`
|
|
1347
|
+
const title = document.createElement('span')
|
|
1348
|
+
const version = typeof chrome !== 'undefined' && chrome.runtime?.getManifest ? chrome.runtime.getManifest().version : '?'
|
|
1349
|
+
const fullTitle = `OpenAgent bridge v${version}`
|
|
1350
|
+
// Folded, the label shrinks to "OA": the fold exists to give the corner back, so the full
|
|
1351
|
+
// name and version retreat to the tooltip.
|
|
1352
|
+
title.textContent = collapsed ? 'OA' : fullTitle
|
|
1353
|
+
if (collapsed) title.title = fullTitle
|
|
1354
|
+
title.style.cssText = 'font-weight:600;color:#a7c080;flex:1'
|
|
1355
|
+
const toggle = document.createElement('button')
|
|
1356
|
+
toggle.textContent = collapsed ? '+' : '−'
|
|
1357
|
+
toggle.title = collapsed ? 'Expand' : 'Collapse'
|
|
1358
|
+
toggle.setAttribute('aria-label', toggle.title)
|
|
1359
|
+
toggle.setAttribute('aria-expanded', String(!collapsed))
|
|
1360
|
+
toggle.style.cssText =
|
|
1361
|
+
'flex:none;width:20px;height:20px;padding:0;cursor:pointer;background:#2d3441;color:#dbdbdb;border:1px solid #3a4150;border-radius:5px;font:inherit;line-height:1'
|
|
1362
|
+
toggle.addEventListener('click', () => {
|
|
1363
|
+
collapsed = !collapsed
|
|
1364
|
+
if (typeof chrome !== 'undefined' && chrome.storage?.local) {
|
|
1365
|
+
try {
|
|
1366
|
+
chrome.storage.local.set({ panelCollapsed: collapsed }, () => void chrome.runtime.lastError)
|
|
1367
|
+
} catch {
|
|
1368
|
+
// Dead context; the choice still holds until the tab reloads.
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
render()
|
|
1372
|
+
})
|
|
1373
|
+
head.append(title, toggle)
|
|
1374
|
+
panel.appendChild(head)
|
|
1375
|
+
// Folded, the OA tab is all there is to draw. The survey above still ran: collapsing
|
|
1376
|
+
// hides the detail, not the bridge, so the daemon keeps hearing from this page.
|
|
1377
|
+
if (collapsed) return
|
|
1378
|
+
const rows = [
|
|
1379
|
+
['question found', winner.choiceFound ? `yes (${winner.choiceVia}${winner === fromFrame ? ', in iframe' : ''})` : `no${choiceNote ? ` (${choiceNote})` : ''}`],
|
|
1380
|
+
['title', winner.choiceTitle ?? '-'],
|
|
1381
|
+
['options', winner.choiceOptions?.length ? winner.choiceOptions.join(' | ') : '-'],
|
|
1382
|
+
['composer', top.composerFound ? top.composerVia : 'not found'],
|
|
1383
|
+
['bridge', bridgeStatus],
|
|
1384
|
+
['answer', answerStatus],
|
|
1385
|
+
// Always shown: the transcript is the half that runs even when no question was asked, so
|
|
1386
|
+
// it needs its own line rather than sharing the question's.
|
|
1387
|
+
['transcript', transcriptStatus],
|
|
1388
|
+
['turn rows', turnRows().length],
|
|
1389
|
+
]
|
|
1390
|
+
if (!winner.choiceFound) {
|
|
1391
|
+
rows.push(
|
|
1392
|
+
['code / pre', `${d.code} / ${d.pre}`],
|
|
1393
|
+
['shadow roots', d.shadowRoots],
|
|
1394
|
+
['iframes', `${d.iframes} (reachable ${d.reachableFrames})`],
|
|
1395
|
+
['"options" body', String(d.optionsInBodyText)],
|
|
1396
|
+
['"options" deep', String(d.optionsInDeepText)],
|
|
1397
|
+
['json candidates', `${d.jsonCandidates} (parse errors ${d.jsonParseErrors})`],
|
|
1398
|
+
...(d.jsonLastError ? [['last parse error', d.jsonLastError]] : []),
|
|
1399
|
+
['frame reported', fromFrame ? 'yes' : 'no'],
|
|
1400
|
+
)
|
|
1401
|
+
}
|
|
1402
|
+
for (const [k, v] of rows) {
|
|
1403
|
+
const line = document.createElement('div')
|
|
1404
|
+
line.style.cssText = 'display:flex;gap:8px;margin:2px 0'
|
|
1405
|
+
const key = document.createElement('span')
|
|
1406
|
+
key.style.cssText = 'color:#7f8797;min-width:104px;flex:none'
|
|
1407
|
+
key.textContent = k
|
|
1408
|
+
const val = document.createElement('span')
|
|
1409
|
+
val.style.cssText = 'word-break:break-word'
|
|
1410
|
+
val.textContent = String(v)
|
|
1411
|
+
line.append(key, val)
|
|
1412
|
+
panel.appendChild(line)
|
|
1413
|
+
}
|
|
1414
|
+
panel.appendChild(button('Copy report', () => {
|
|
1415
|
+
void navigator.clipboard.writeText(JSON.stringify(latest, null, 2))
|
|
1416
|
+
}))
|
|
1417
|
+
// Fills without submitting, on purpose: it proves the write path exists without the
|
|
1418
|
+
// extension ever speaking for the user.
|
|
1419
|
+
panel.appendChild(button('Fill composer (does not send)', () => {
|
|
1420
|
+
const composer = findComposer()
|
|
1421
|
+
if (!composer) return
|
|
1422
|
+
const text = winner.choiceOptions?.[0] ?? 'test'
|
|
1423
|
+
if (composer.via === 'textarea') {
|
|
1424
|
+
composer.el.value = text
|
|
1425
|
+
composer.el.dispatchEvent(new Event('input', { bubbles: true }))
|
|
1426
|
+
} else {
|
|
1427
|
+
composer.el.focus()
|
|
1428
|
+
document.execCommand('insertText', false, text)
|
|
1429
|
+
}
|
|
1430
|
+
}))
|
|
1431
|
+
})
|
|
1432
|
+
|
|
1433
|
+
render()
|
|
1434
|
+
watch(render)
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
/**
|
|
1438
|
+
* Re-survey when the page actually changes, plus a slow heartbeat.
|
|
1439
|
+
*
|
|
1440
|
+
* Not a fast `setInterval`: this is designed to run in a background tab, and Chrome clamps timers
|
|
1441
|
+
* in a tab hidden for more than about five minutes to roughly once a minute. The session's own
|
|
1442
|
+
* stream mutates the DOM whenever anything happens, so an observer catches it immediately and the
|
|
1443
|
+
* interval is only a backstop for a mutation we failed to see.
|
|
1444
|
+
*/
|
|
1445
|
+
function watch(run) {
|
|
1446
|
+
let queued = false
|
|
1447
|
+
let interval
|
|
1448
|
+
// Reloading the extension invalidates this script's runtime but leaves it running in the tab,
|
|
1449
|
+
// where every chrome.* call now throws. Those throws land on the extension's error page and
|
|
1450
|
+
// read as bugs, so a dead context stops watching instead of erroring once a minute forever.
|
|
1451
|
+
const alive = () => {
|
|
1452
|
+
try {
|
|
1453
|
+
if (typeof chrome === 'undefined' || chrome.runtime?.id) return true
|
|
1454
|
+
} catch {
|
|
1455
|
+
// Reading chrome.runtime itself can throw once the context is gone.
|
|
1456
|
+
}
|
|
1457
|
+
observer.disconnect()
|
|
1458
|
+
watchers.delete(observer)
|
|
1459
|
+
clearInterval(interval)
|
|
1460
|
+
return false
|
|
1461
|
+
}
|
|
1462
|
+
const observer = new MutationObserver(() => {
|
|
1463
|
+
if (queued || !alive()) return
|
|
1464
|
+
queued = true
|
|
1465
|
+
setTimeout(() => {
|
|
1466
|
+
queued = false
|
|
1467
|
+
run()
|
|
1468
|
+
}, 250)
|
|
1469
|
+
})
|
|
1470
|
+
observer.observe(document.documentElement, OBSERVED)
|
|
1471
|
+
watchers.add(observer)
|
|
1472
|
+
interval = setInterval(() => {
|
|
1473
|
+
if (alive()) run()
|
|
1474
|
+
}, 30 * POLL_MS)
|
|
1475
|
+
}
|