@moretea-labs/forge 1.6.0 → 1.7.1
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/AGENTS.md +4 -0
- package/CHANGELOG.md +39 -0
- package/CLAUDE.md +4 -0
- package/README.md +2 -2
- package/README.zh-CN.md +1 -1
- package/SKILL.md +2 -2
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/agents/openai.yaml +1 -1
- package/assets/hooks/hook-input.sh +11 -8
- package/assets/hooks/lib/session-state.sh +10 -7
- package/assets/hooks/lib/workflow-state.sh +13 -177
- package/assets/hooks/post-tool-observer.sh +8 -2
- package/assets/hooks/prompt-guard.sh +5 -81
- package/assets/hooks/session-start-context.sh +56 -1
- package/assets/partials/05-workflow.partial.md +1 -1
- package/assets/partials-agents/02-operating-mode.partial.md +2 -0
- package/assets/partials-agents/03-orchestration.partial.md +1 -1
- package/assets/plugin-registry.v1.json +3 -3
- package/assets/reference-configs/document-generation.md +1 -1
- package/assets/reference-configs/harness-overview.md +1 -1
- package/assets/reference-configs/sprint-contracts.md +3 -3
- package/assets/skill-version.json +2 -2
- package/assets/templates/contract.template.md +2 -7
- package/assets/templates/helpers/capture-plan.sh +2 -2
- package/assets/templates/helpers/check-architecture-sync.sh +17 -0
- package/assets/templates/helpers/check-task-workflow.sh +4 -4
- package/assets/templates/helpers/contract-worktree.sh +1 -21
- package/assets/templates/helpers/ensure-task-workflow.sh +11 -36
- package/assets/templates/helpers/inspect-project-state.ts +8 -6
- package/assets/templates/helpers/migrate-project-template.sh +22 -45
- package/assets/templates/helpers/migrate-workflow-docs.ts +9 -7
- package/assets/templates/helpers/new-plan.sh +1 -1
- package/assets/templates/helpers/plan-to-todo.sh +12 -37
- package/assets/templates/helpers/ship-worktrees.sh +2 -29
- package/assets/templates/helpers/verify-contract.sh +1 -111
- package/assets/templates/helpers/verify-sprint.sh +3 -180
- package/assets/templates/helpers/workflow-contract.ts +5 -1
- package/assets/templates/plan.template.md +1 -1
- package/assets/templates/review.template.md +9 -34
- package/assets/workflow-contract.v1.json +10 -19
- package/docs/README.md +10 -10
- package/docs/architecture/CURRENT.md +178 -0
- package/docs/architecture/EVOLUTION.md +42 -0
- package/docs/architecture/current/README.md +4 -156
- package/docs/architecture/decisions/20260803-release-identity-binding-and-exit-policy.md +2 -2
- package/docs/architecture/decisions/20260823-immediate-controller-relay.md +53 -0
- package/docs/architecture/decisions/20260826-browser-runtime-v2.md +73 -0
- package/docs/architecture/history/README.md +7 -0
- package/docs/architecture/{chatgpt-handoff-facade.md → history/chatgpt-handoff-facade.md} +4 -0
- package/docs/architecture/{global-hook-runtime.md → history/global-hook-runtime.md} +7 -3
- package/docs/architecture/{ios-semantic-automation-provider-v2.md → history/ios-semantic-automation-provider-v2.md} +6 -2
- package/docs/architecture/history.md +3 -34
- package/docs/architecture/index.md +15 -98
- package/docs/architecture/modules/controller-runtime/transport-lifecycle.md +1 -1
- package/docs/architecture/reviews/2026-08-22-semantic-harness-convergence-acceptance.md +39 -0
- package/docs/architecture/versions/1.6.md +38 -0
- package/docs/architecture/versions/1.7.md +41 -0
- package/docs/forge-chatgpt-browser-engine.md +2 -2
- package/docs/forge-chatgpt-mcp-setup.md +4 -28
- package/docs/forge-chatgpt-supervised-automation.md +3 -3
- package/docs/forge-github-issue-launcher.md +1 -1
- package/docs/forge-plugin-management.md +1 -1
- package/docs/forge-runtime-self-healing-loop.md +1 -1
- package/docs/operations/controller-browser-plugin.md +26 -6
- package/docs/operations/controller-performance-and-502.md +143 -0
- package/docs/operations/controller-reliability-runbook.md +1 -1
- package/docs/operations/features.md +2 -2
- package/docs/operations/github-repository.md +1 -1
- package/docs/operations/homebrew.md +2 -2
- package/docs/operations/mcp-tool-exposure.md +5 -3
- package/docs/wiki/Architecture.md +6 -6
- package/docs/wiki/Implementation.md +2 -2
- package/docs/wiki/Integrations.md +2 -2
- package/docs/wiki/Work-Lifecycle.md +1 -1
- package/package.json +33 -11
- package/scripts/activate-source-baseline.ts +17 -13
- package/scripts/architecture-event.ts +2 -820
- package/scripts/architecture-queue.sh +2 -645
- package/scripts/archive-architecture-request.sh +2 -242
- package/scripts/archive-workflow.sh +1 -291
- package/scripts/benchmark-background-check-overlap.ts +228 -0
- package/scripts/benchmark-mcp-host.sh +117 -0
- package/scripts/benchmark-mcp-transport.ts +170 -0
- package/scripts/benchmark-quality-harness.ts +146 -0
- package/scripts/benchmark-route-session-concurrency.ts +207 -6
- package/scripts/benchmark-thin-harness-mcp-e2e.ts +7 -1
- package/scripts/benchmark-typescript-navigation.ts +186 -0
- package/scripts/benchmark-work-efficiency.ts +334 -0
- package/scripts/capability-config.ts +2 -392
- package/scripts/capability-resolver.ts +2 -503
- package/scripts/capture-plan.sh +1 -332
- package/scripts/check-agent-tooling.sh +2 -1558
- package/scripts/check-architecture-sync.sh +2 -371
- package/scripts/check-brain-manifest.sh +2 -253
- package/scripts/check-context-files.sh +2 -47
- package/scripts/check-deploy-sql-order.sh +2 -115
- package/scripts/check-mcp-compatibility.ts +14 -0
- package/scripts/check-runtime-architecture.mjs +349 -79
- package/scripts/check-skill-version.ts +3 -191
- package/scripts/check-source-duplication.mjs +61 -0
- package/scripts/check-stable-baseline.ts +92 -0
- package/scripts/check-task-sync.sh +2 -63
- package/scripts/check-task-workflow.sh +2 -1000
- package/scripts/codex-handoff-resume.sh +2 -267
- package/scripts/context-contract-sync.sh +2 -514
- package/scripts/contract-run.ts +2 -463
- package/scripts/contract-worktree.sh +1 -722
- package/scripts/ensure-task-workflow.sh +1 -1225
- package/scripts/host-temporary-cloud-mcp-e2e.sh +265 -0
- package/scripts/inspect-project-state.ts +4 -279
- package/scripts/install-standalone-recovery.ts +24 -0
- package/scripts/lib/project-init-lib.sh +22 -46
- package/scripts/lib/run-forge-helper.sh +19 -0
- package/scripts/lib/run-forge-helper.ts +26 -0
- package/scripts/migrate-project-template.sh +1 -1189
- package/scripts/migrate-workflow-docs.ts +4 -412
- package/scripts/new-plan.sh +1 -180
- package/scripts/new-spec.sh +1 -47
- package/scripts/new-sprint.sh +1 -46
- package/scripts/plan-to-todo.sh +1 -796
- package/scripts/prepare-codex-handoff.sh +2 -201
- package/scripts/prepare-handoff.sh +1 -78
- package/scripts/refresh-current-status.sh +2 -467
- package/scripts/run-governed-gate.ts +5 -1
- package/scripts/select-agent-context-blocks.sh +2 -64
- package/scripts/ship-worktrees.sh +1 -558
- package/scripts/smoke-ios-physical-device.ts +4 -2
- package/scripts/smoke-runtime-recovery.ts +1 -35
- package/scripts/smoke-schedule-engine.ts +1 -20
- package/scripts/sprint-backlog.sh +1 -834
- package/scripts/stage-runtime-release.ts +2 -1
- package/scripts/summarize-failures.sh +2 -123
- package/scripts/switch-plan.sh +1 -150
- package/scripts/sync-brain-docs.sh +2 -379
- package/scripts/sync-generated-authority.mjs +175 -0
- package/scripts/verify-contract.sh +2 -703
- package/scripts/verify-forge-runtime.sh +0 -1
- package/scripts/verify-sprint.sh +1 -677
- package/scripts/workflow-contract.ts +4 -205
- package/scripts/workstream-sync.sh +2 -261
- package/src/cli/agent-jobs/executor-health.ts +1 -1
- package/src/cli/commands/init-hook.ts +3 -3
- package/src/cli/commands/mcp.ts +9 -5
- package/src/cli/commands/plugin.ts +12 -3
- package/src/cli/commands/recovery.ts +25 -3
- package/src/cli/commands/runtime.ts +55 -3
- package/src/cli/commands/security.ts +12 -1
- package/src/cli/commands/setup-profile.ts +31 -21
- package/src/cli/controller/check-runner.ts +113 -5
- package/src/cli/controller/composite-result.ts +1 -1
- package/src/cli/controller/context/focus.ts +51 -0
- package/src/cli/controller/context/instruction-context.ts +114 -0
- package/src/cli/controller/context/known-paths.ts +76 -0
- package/src/cli/controller/context/query-planning.ts +110 -0
- package/src/cli/controller/context/rule-resolution.ts +532 -0
- package/src/cli/controller/context/source-materializer.ts +278 -0
- package/src/cli/controller/context/structural.ts +16 -0
- package/src/cli/controller/context/types.ts +286 -0
- package/src/cli/controller/context-pack.ts +828 -487
- package/src/cli/controller/launch-agents.ts +77 -2
- package/src/cli/controller/operational-plan.ts +2 -2
- package/src/cli/controller/work-mode.ts +6 -4
- package/src/cli/editing/edit-session.ts +60 -7
- package/src/cli/github/contracts.ts +16 -0
- package/src/cli/github/github.ts +135 -16
- package/src/cli/github/session.ts +2 -1
- package/src/cli/installer/managed-entries.ts +15 -1
- package/src/cli/local-bridge/console-automations.ts +54 -78
- package/src/cli/local-bridge/console-view-models.ts +1 -35
- package/src/cli/local-bridge/facade-api.ts +37 -264
- package/src/cli/local-bridge/frontend/src/App.tsx +3 -3
- package/src/cli/local-bridge/frontend/src/features/automations/AutomationsView.tsx +23 -13
- package/src/cli/local-bridge/frontend/src/features/capabilities/CapabilitiesView.tsx +37 -4
- package/src/cli/local-bridge/frontend/src/features/overview/OverviewView.tsx +6 -4
- package/src/cli/local-bridge/frontend/src/lib/api.ts +1 -6
- package/src/cli/local-bridge/frontend/src/lib/routes.ts +1 -1
- package/src/cli/local-bridge/frontend/src/shell/Sidebar.tsx +2 -2
- package/src/cli/local-bridge/frontend/src/styles.css +1 -1
- package/src/cli/local-bridge/frontend/src/types.ts +5 -8
- package/src/cli/local-bridge/job-store.ts +1 -490
- package/src/cli/local-bridge/server.ts +2 -515
- package/src/cli/local-bridge/ui-dist/app.css +1 -1
- package/src/cli/local-bridge/ui-dist/app.js +3703 -3757
- package/src/cli/mcp/auth.ts +5 -5
- package/src/cli/mcp/instructions.ts +3 -2
- package/src/cli/mcp/legacy-tool-service.ts +10 -222
- package/src/cli/mcp/multi-repository.ts +16 -5
- package/src/cli/mcp/oauth.ts +175 -26
- package/src/cli/mcp/post-finalize-work-attribution.ts +150 -0
- package/src/cli/mcp/repository-tools.ts +282 -146
- package/src/cli/mcp/server.ts +586 -116
- package/src/cli/mcp/tool-capability-cache.ts +1 -1
- package/src/cli/mcp/tool-contract.ts +14 -0
- package/src/cli/mcp/toolset-selection.ts +1 -1
- package/src/cli/mcp/toolset.ts +44 -7
- package/src/cli/mcp/transports/http.ts +124 -14
- package/src/cli/mcp/transports/session-registry.ts +43 -5
- package/src/cli/mcp/types.ts +3 -3
- package/src/cli/repositories/command-classifier.ts +103 -21
- package/src/cli/repositories/command-executor.ts +78 -526
- package/src/cli/repositories/command-process.ts +108 -0
- package/src/cli/repositories/command-scope.ts +99 -41
- package/src/cli/repositories/controller-home.ts +7 -8
- package/src/cli/repositories/git-command-executor.ts +2 -0
- package/src/cli/repositories/locks.ts +53 -16
- package/src/cli/repositories/repository-snapshot.ts +124 -0
- package/src/cli/repositories/selected-path-actions.ts +17 -9
- package/src/cli/repositories/structured-git.ts +118 -6
- package/src/cli/repository/inspector.ts +563 -65
- package/src/cli/repository/session-cache.ts +58 -17
- package/src/cli/v81-entry.ts +1 -4
- package/src/effects/fs-transaction.ts +85 -17
- package/src/runtime/context/codegraph-read-provider.ts +233 -2
- package/src/runtime/context/codegraph-sidecar.cjs +74 -40
- package/src/runtime/context/generic-lsp-provider.ts +355 -0
- package/src/runtime/context/lsp-client.ts +283 -0
- package/src/runtime/context/semantic-navigation-contract.ts +61 -0
- package/src/runtime/context/semantic-navigation.ts +223 -0
- package/src/runtime/context/swift-navigation.ts +565 -0
- package/src/runtime/context/typescript-navigation.ts +226 -0
- package/src/runtime/control-plane/dispatch-priority.ts +28 -0
- package/src/runtime/control-plane/execution/direct-edit-work-completion.ts +48 -16
- package/src/runtime/control-plane/execution/edit-validation-coordinator.ts +86 -24
- package/src/runtime/control-plane/execution/repository-work-attribution.ts +106 -0
- package/src/runtime/control-plane/execution/retained-work-resume.ts +203 -0
- package/src/runtime/control-plane/execution/session-store.ts +18 -0
- package/src/runtime/control-plane/execution/verification-evidence.ts +58 -3
- package/src/runtime/control-plane/execution/work-completion-authority.ts +58 -0
- package/src/runtime/control-plane/execution/work-evidence-policy.ts +200 -0
- package/src/runtime/control-plane/execution/work-execution-support.ts +386 -0
- package/src/runtime/control-plane/execution/work-finalization-service.ts +1913 -0
- package/src/runtime/control-plane/execution/work-handle-authority.ts +96 -0
- package/src/runtime/control-plane/execution/work-handle-store.ts +27 -2
- package/src/runtime/control-plane/execution/work-lifecycle-audit.ts +258 -0
- package/src/runtime/control-plane/execution/work-operation-service.ts +468 -0
- package/src/runtime/control-plane/execution/work-path-scope.ts +37 -0
- package/src/runtime/control-plane/execution/work-preparation-service.ts +479 -0
- package/src/runtime/control-plane/execution/work-process-evidence.ts +129 -0
- package/src/runtime/control-plane/execution/work-remote-delivery.ts +215 -0
- package/src/runtime/control-plane/execution/work-task-receipt.ts +22 -18
- package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +156 -11
- package/src/runtime/control-plane/execution/work-validation-reconciler.ts +22 -14
- package/src/runtime/control-plane/execution/work-verification-context.ts +56 -0
- package/src/runtime/control-plane/execution/work-verification-service.ts +388 -0
- package/src/runtime/control-plane/execution/work-verification-snapshot.ts +90 -9
- package/src/runtime/control-plane/facade/capability-registry.ts +129 -25
- package/src/runtime/control-plane/facade/controller-round-relay.ts +1118 -0
- package/src/runtime/control-plane/facade/controller-session-store.ts +302 -12
- package/src/runtime/control-plane/facade/goal-workloop-access.ts +17 -1
- package/src/runtime/control-plane/facade/goal-workloop.ts +928 -190
- package/src/runtime/control-plane/facade/index.ts +3 -0
- package/src/runtime/control-plane/facade/plan-contract-store.ts +275 -42
- package/src/runtime/control-plane/facade/repository-work-admission.ts +147 -0
- package/src/runtime/control-plane/facade/requirement-authority.ts +83 -0
- package/src/runtime/control-plane/facade/semantic-admission.ts +205 -0
- package/src/runtime/control-plane/facade/suggested-actions.ts +5 -1
- package/src/runtime/control-plane/facade/types.ts +90 -10
- package/src/runtime/control-plane/facade/work-contract-store.ts +157 -261
- package/src/runtime/control-plane/facade/work-state-machine.ts +250 -0
- package/src/runtime/control-plane/generated-cache-retention.ts +271 -0
- package/src/runtime/control-plane/global-scheduler/config.ts +44 -0
- package/src/runtime/control-plane/global-scheduler/dispatch-capacity.ts +80 -0
- package/src/runtime/control-plane/global-scheduler/durable-admission.ts +46 -0
- package/src/runtime/control-plane/global-scheduler/maintenance.ts +152 -0
- package/src/runtime/control-plane/global-scheduler/projection-refresh.ts +84 -0
- package/src/runtime/control-plane/global-scheduler/reconciliation.ts +3 -3
- package/src/runtime/control-plane/global-scheduler/scheduler.ts +278 -543
- package/src/runtime/control-plane/global-scheduler/source-scan.ts +59 -0
- package/src/runtime/control-plane/global-scheduler/state.ts +88 -0
- package/src/runtime/control-plane/global-scheduler/worker-attachment.ts +57 -0
- package/src/runtime/control-plane/global-scheduler/worker-exit-decision.ts +38 -0
- package/src/runtime/control-plane/global-scheduler/worker-exit-reconciler.ts +175 -0
- package/src/runtime/control-plane/global-scheduler/worker-launch.ts +111 -0
- package/src/runtime/control-plane/global-scheduler/worker-lifecycle-store.ts +64 -0
- package/src/runtime/control-plane/global-scheduler/worker-lifecycle.ts +150 -0
- package/src/runtime/control-plane/global-scheduler/worker-process.ts +119 -0
- package/src/runtime/control-plane/global-scheduler/worker-stderr.ts +60 -0
- package/src/runtime/control-plane/launcher/chatgpt-work-binding-store.ts +63 -0
- package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +365 -117
- package/src/runtime/control-plane/launcher/launch-reservation-store.ts +89 -0
- package/src/runtime/control-plane/launcher/thin-launcher.ts +244 -11
- package/src/runtime/control-plane/persistence/controller-home-migration.ts +487 -0
- package/src/runtime/control-plane/persistence/requirement-store.ts +0 -12
- package/src/runtime/control-plane/persistence/sqlite-store.ts +33 -2
- package/src/runtime/control-plane/release-retention.ts +62 -2
- package/src/runtime/control-plane/repo-actor/registry.ts +4 -2
- package/src/runtime/control-plane/routing/route-policy.ts +24 -21
- package/src/runtime/control-plane/routing/workspace-admission.ts +70 -0
- package/src/runtime/control-plane/runtime-status-client.ts +0 -2
- package/src/runtime/diagnostics/mcp-timing.ts +13 -0
- package/src/runtime/diagnostics/mcp-transport-benchmark.ts +129 -0
- package/src/runtime/diagnostics/performance.ts +24 -1
- package/src/runtime/evidence/event-ledger.ts +1 -1
- package/src/runtime/evidence/process-check-execution.ts +18 -0
- package/src/runtime/execution/jobs/child-reference.ts +6 -2
- package/src/runtime/execution/jobs/store.ts +58 -4
- package/src/runtime/execution/jobs/types.ts +1 -1
- package/src/runtime/execution/managed-workspace.ts +203 -1
- package/src/runtime/execution/process-runtime/check-facade.ts +51 -4
- package/src/runtime/execution/process-runtime/check-result.ts +88 -0
- package/src/runtime/execution/process-runtime/check-runner-sidecar.ts +7 -1
- package/src/runtime/execution/process-runtime/check-scheduling.ts +114 -0
- package/src/runtime/execution/process-runtime/command-facade.ts +367 -40
- package/src/runtime/execution/process-runtime/gc.ts +38 -6
- package/src/runtime/execution/process-runtime/lightweight-managed.ts +1201 -0
- package/src/runtime/execution/process-runtime/persisted-check.ts +51 -5
- package/src/runtime/execution/process-runtime/resource-claims.ts +10 -6
- package/src/runtime/execution/process-runtime/runtime.ts +53 -3
- package/src/runtime/execution/process-runtime/store.ts +1 -0
- package/src/runtime/execution/process-runtime/types.ts +4 -12
- package/src/runtime/execution/thin-harness/async-process.ts +12 -2
- package/src/runtime/execution/thin-harness/search-worker.ts +54 -5
- package/src/runtime/execution/work-activity.ts +21 -0
- package/src/runtime/execution/workers/executor.ts +18 -61
- package/src/runtime/gateway/mcp/execution-tools.ts +35 -1852
- package/src/runtime/gateway/mcp/legacy-ios-tool-adapter.ts +61 -0
- package/src/runtime/gateway/mcp/process-tools.ts +83 -37
- package/src/runtime/gateway/mcp/router.ts +186 -84
- package/src/runtime/gateway/mcp/runtime-tool-definitions.ts +565 -0
- package/src/runtime/gateway/mcp/runtime-tools.ts +2378 -1947
- package/src/runtime/plugins/app-store-connect-adapter.ts +216 -47
- package/src/runtime/plugins/browser-adapter.ts +2662 -829
- package/src/runtime/plugins/browser-automation-service.ts +10 -0
- package/src/runtime/plugins/browser-handoff-host.ts +2 -25
- package/src/runtime/plugins/browser-handoff.ts +110 -5
- package/src/runtime/plugins/browser-macos-bridge.ts +701 -63
- package/src/runtime/plugins/browser-manifest-surface.ts +603 -0
- package/src/runtime/plugins/browser-native-session-service.ts +222 -0
- package/src/runtime/plugins/browser-node-bridge.ts +6 -34
- package/src/runtime/plugins/browser-provider-registry.ts +109 -0
- package/src/runtime/plugins/browser-runtime-contract.ts +154 -0
- package/src/runtime/plugins/browser-runtime.ts +193 -0
- package/src/runtime/plugins/browser-session-authority.ts +332 -0
- package/src/runtime/plugins/browser-session-store.ts +121 -0
- package/src/runtime/plugins/browser-session-types.ts +98 -0
- package/src/runtime/plugins/capability-authorization-grants.ts +354 -0
- package/src/runtime/plugins/desktop-operator-registration.ts +10 -4
- package/src/runtime/plugins/external-adapter.ts +358 -3
- package/src/runtime/plugins/external-unix-socket.ts +7 -1
- package/src/runtime/plugins/first-party-registry.ts +2 -0
- package/src/runtime/plugins/github-adapter.ts +125 -0
- package/src/runtime/plugins/ios/agent-device-provider.ts +1 -1
- package/src/runtime/plugins/ios/agent-device-typed-provider.ts +52 -9
- package/src/runtime/plugins/ios/remote-xpc-hid.ts +1171 -0
- package/src/runtime/plugins/ios-adapter.ts +106 -16
- package/src/runtime/plugins/ios-agent-device.ts +234 -64
- package/src/runtime/plugins/ios-physical-device.ts +1143 -392
- package/src/runtime/plugins/lightweight-action.ts +153 -0
- package/src/runtime/plugins/local-system-adapter.ts +123 -6
- package/src/runtime/plugins/macos-capability-broker.ts +47 -9
- package/src/runtime/plugins/plugin-action-sidecar.ts +59 -0
- package/src/runtime/plugins/plugin-management-adapter.ts +49 -8
- package/src/runtime/plugins/resend-adapter.ts +1 -1
- package/src/runtime/plugins/store.ts +330 -33
- package/src/runtime/plugins/types.ts +38 -3
- package/src/runtime/plugins/xiaohongshu-publish.ts +515 -0
- package/src/runtime/projections/git-status-sampler.ts +16 -16
- package/src/runtime/projections/materialized-view.ts +28 -10
- package/src/runtime/recovery/maintenance-executor.ts +426 -25
- package/src/runtime/root/mcp-transport.ts +180 -10
- package/src/runtime/root/package-connector-service.ts +230 -10
- package/src/runtime/root/package-runtime-release.ts +143 -17
- package/src/runtime/root/package-runtime-service.ts +461 -22
- package/src/runtime/root/release-manifest.ts +144 -18
- package/src/runtime/root/release-materialize.ts +125 -32
- package/src/runtime/root/release-store.ts +48 -11
- package/src/runtime/root/runtime.ts +23 -5
- package/src/runtime/root/service.ts +100 -29
- package/src/runtime/root/types.ts +12 -0
- package/src/runtime/safe-tooling/ios-development.ts +321 -63
- package/src/runtime/shared/trusted-executable.ts +62 -0
- package/src/runtime/shared/trusted-node-executable.ts +48 -0
- package/src/runtime/standalone-recovery/core.ts +1079 -240
- package/src/runtime/standalone-recovery/entry.ts +100 -24
- package/src/runtime/standalone-recovery/installer.ts +20 -4
- package/src/runtime/standalone-recovery/service-labels.ts +2 -0
- package/src/runtime/standalone-recovery/watchdog-heartbeat.ts +160 -0
- package/src/runtime/workflow/schedules/browser-probe.ts +81 -3
- package/src/runtime/workflow/schedules/engine.ts +364 -202
- package/src/runtime/workflow/schedules/github-issue-watch.ts +158 -0
- package/src/runtime/workflow/schedules/settlement.ts +64 -17
- package/src/runtime/workflow/schedules/store.ts +209 -19
- package/src/runtime/workflow/schedules/types.ts +2 -4
- package/src/runtime/workflow/schedules/work-continuation.ts +26 -13
- package/src/testing/test-governance.ts +8 -40
- package/assets/reference-configs/heartbeat-triage.md +0 -57
- package/assets/templates/helpers/harness-trace-grade.sh +0 -150
- package/assets/templates/helpers/heartbeat-triage.sh +0 -361
- package/assets/templates/helpers/maintenance-triage.sh +0 -167
- package/docs/architecture/current/approved-target-architecture.zh-CN.md +0 -1501
- package/docs/architecture/current/architecture-invariants.md +0 -331
- package/docs/architecture/current/automation-and-schedule-engine.md +0 -459
- package/docs/architecture/current/codegraph-context-provider.md +0 -238
- package/docs/architecture/current/control-plane-authority-inventory.md +0 -94
- package/docs/architecture/current/controller-repository-decoupling.md +0 -148
- package/docs/architecture/current/dispatch-and-agent-strategy.md +0 -373
- package/docs/architecture/current/entity-model.md +0 -612
- package/docs/architecture/current/external-plugin-governance.md +0 -396
- package/docs/architecture/current/failure-recovery.md +0 -447
- package/docs/architecture/current/governance.md +0 -213
- package/docs/architecture/current/human-interaction-plane.md +0 -87
- package/docs/architecture/current/implementation-status.md +0 -128
- package/docs/architecture/current/job-and-run-lifecycle.md +0 -413
- package/docs/architecture/current/legacy-governance.md +0 -58
- package/docs/architecture/current/local-workspace-target-grants.md +0 -70
- package/docs/architecture/current/migration-roadmap.md +0 -105
- package/docs/architecture/current/multi-repository-execution.md +0 -371
- package/docs/architecture/current/operations-runbook.md +0 -45
- package/docs/architecture/current/peer-supercontroller-runtime.md +0 -32
- package/docs/architecture/current/personal-assistant-plugin-baseline.md +0 -206
- package/docs/architecture/current/repository-access-modes.md +0 -102
- package/docs/architecture/current/route-policy.md +0 -15
- package/docs/architecture/current/route-session-concurrency.md +0 -85
- package/docs/architecture/current/runtime-architecture-simplification.md +0 -214
- package/docs/architecture/current/runtime-directory-map.md +0 -49
- package/docs/architecture/current/runtime-health-and-resource-lifecycle.md +0 -85
- package/docs/architecture/current/scheduler-and-resource-claims.md +0 -422
- package/docs/architecture/current/session-aware-execution-and-authorization.md +0 -77
- package/docs/architecture/current/system-overview.md +0 -169
- package/docs/architecture/current/target-requirements-traceability.md +0 -202
- package/docs/architecture/current/thin-harness-governance-policy.md +0 -32
- package/docs/architecture/current/thin-harness-v1.md +0 -321
- package/docs/architecture/current/verification-and-release-gates.md +0 -391
- package/docs/forge-autonomous-goal-loop.md +0 -9
- package/docs/forge-provider-configuration.md +0 -7
- package/docs/operations/chatgpt-personal-assistant-runtime.md +0 -93
- package/docs/operations/gmail-assistant-routines.md +0 -186
- package/docs/operations/personal-assistant-operator-guide.md +0 -61
- package/docs/operations/provider-configuration.md +0 -31
- package/scripts/harness-trace-grade.sh +0 -150
- package/scripts/heartbeat-triage.sh +0 -361
- package/scripts/maintenance-triage.sh +0 -167
- package/src/cli/local-bridge/frontend/src/features/settings/SettingsView.tsx +0 -2
- package/src/cli/repositories/goal-registry.ts +0 -239
- package/src/runtime/assistant/action-proposals.ts +0 -279
- package/src/runtime/assistant/intent.ts +0 -395
- package/src/runtime/assistant/model-provider.ts +0 -460
- package/src/runtime/assistant/openapi.ts +0 -223
- package/src/runtime/assistant/policy.ts +0 -89
- package/src/runtime/assistant/readiness.ts +0 -258
- package/src/runtime/assistant/routine-runtime.ts +0 -477
- package/src/runtime/assistant/schedule-binding.ts +0 -171
- package/src/runtime/assistant/standing-grants.ts +0 -375
- package/src/runtime/assistant/store.ts +0 -243
- package/src/runtime/assistant/types.ts +0 -135
- package/src/runtime/control-plane/goal-loop/config-facade.ts +0 -700
- package/src/runtime/control-plane/goal-loop/config-store.ts +0 -570
- package/src/runtime/control-plane/goal-loop/config-types.ts +0 -213
- package/src/runtime/control-plane/goal-loop/executor-router.ts +0 -109
- package/src/runtime/control-plane/goal-loop/failure-taxonomy.ts +0 -149
- package/src/runtime/control-plane/goal-loop/goal-contract-store.ts +0 -304
- package/src/runtime/control-plane/goal-loop/goal-loop-engine.ts +0 -1001
- package/src/runtime/control-plane/goal-loop/handoff-packet.ts +0 -160
- package/src/runtime/control-plane/goal-loop/index.ts +0 -13
- package/src/runtime/control-plane/goal-loop/local-tools.ts +0 -290
- package/src/runtime/control-plane/goal-loop/policy-gates.ts +0 -203
- package/src/runtime/control-plane/goal-loop/provider-dispatch.ts +0 -93
- package/src/runtime/control-plane/goal-loop/provider-registry.ts +0 -545
- package/src/runtime/control-plane/goal-loop/types.ts +0 -378
- package/src/runtime/control-plane/startup-recovery.ts +0 -198
- package/src/runtime/gateway/mcp/resource-policy.ts +0 -186
- package/src/runtime/personal-assistant/gmail-triage-manager.ts +0 -231
- package/src/runtime/personal-assistant/reporting-runtime.ts +0 -344
- package/src/runtime/personal-assistant/triage-runtime.ts +0 -748
- package/src/runtime/workflow/findings/store.ts +0 -204
- package/src/runtime/workflow/findings/types.ts +0 -58
- package/src/runtime/workflow/portfolio/engine.ts +0 -20
- package/src/runtime/workflow/portfolio/store.ts +0 -185
- package/src/runtime/workflow/portfolio/types.ts +0 -29
|
@@ -0,0 +1,1171 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs';
|
|
3
|
+
import { dirname, join } from 'path';
|
|
4
|
+
import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from 'child_process';
|
|
5
|
+
import { createInterface } from 'readline';
|
|
6
|
+
import { performance } from 'perf_hooks';
|
|
7
|
+
import { AssistantPluginError } from '../errors';
|
|
8
|
+
|
|
9
|
+
const TOOLCHAIN_VERSION = '10.2.1';
|
|
10
|
+
const WORKER_IDLE_MS = 15 * 60_000;
|
|
11
|
+
const WORKER_STARTUP_MS = 12_000;
|
|
12
|
+
const MUTATION_READY_BUDGET_MS = 2_500;
|
|
13
|
+
const REQUEST_TIMEOUT_MS = 12_000;
|
|
14
|
+
const RSD_ENDPOINT_CACHE_TTL_MS = 30_000;
|
|
15
|
+
const RSD_ENDPOINT_PERSISTED_TTL_MS = 10 * 60_000;
|
|
16
|
+
const MAX_TEXT_LENGTH = 2048;
|
|
17
|
+
const MAX_STDERR = 8 * 1024;
|
|
18
|
+
|
|
19
|
+
export interface RemoteXpcHidInput {
|
|
20
|
+
controllerHome: string;
|
|
21
|
+
deviceIdentifier: string;
|
|
22
|
+
udid: string;
|
|
23
|
+
width?: number;
|
|
24
|
+
height?: number;
|
|
25
|
+
action: 'tap' | 'swipe' | 'type';
|
|
26
|
+
textMode?: 'auto' | 'keys' | 'pasteboard';
|
|
27
|
+
replaceExisting?: boolean;
|
|
28
|
+
x?: number;
|
|
29
|
+
y?: number;
|
|
30
|
+
x2?: number;
|
|
31
|
+
y2?: number;
|
|
32
|
+
durationMs?: number;
|
|
33
|
+
text?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface RemoteXpcHidResult {
|
|
37
|
+
backend: 'remote-xpc-hid';
|
|
38
|
+
reusedWorker: boolean;
|
|
39
|
+
endpoint: { host: string; port: number };
|
|
40
|
+
result: Record<string, unknown>;
|
|
41
|
+
timings: {
|
|
42
|
+
workerStartupMs: number;
|
|
43
|
+
workerReadyMs: number;
|
|
44
|
+
requestMs: number;
|
|
45
|
+
hidMs?: number;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface RsdEndpoint {
|
|
50
|
+
host: string;
|
|
51
|
+
port: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface PendingRequest {
|
|
55
|
+
resolve(value: Record<string, unknown>): void;
|
|
56
|
+
reject(error: Error): void;
|
|
57
|
+
timer: NodeJS.Timeout;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
class WorkerRequestError extends Error {
|
|
61
|
+
readonly phase?: string;
|
|
62
|
+
readonly mutationDispatched?: boolean;
|
|
63
|
+
|
|
64
|
+
constructor(message: string, details: { phase?: string; mutationDispatched?: boolean } = {}) {
|
|
65
|
+
super(message);
|
|
66
|
+
this.name = 'WorkerRequestError';
|
|
67
|
+
this.phase = details.phase;
|
|
68
|
+
this.mutationDispatched = details.mutationDispatched;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface WarmupFailure {
|
|
73
|
+
generation: number;
|
|
74
|
+
recordedAt: number;
|
|
75
|
+
message: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface EndpointCacheEntry {
|
|
79
|
+
observedAt: number;
|
|
80
|
+
endpoints: RsdEndpoint[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface WorkerRecord {
|
|
84
|
+
key: string;
|
|
85
|
+
endpoint: RsdEndpoint;
|
|
86
|
+
process: ChildProcessWithoutNullStreams;
|
|
87
|
+
pending: Map<string, PendingRequest>;
|
|
88
|
+
ready: Promise<Record<string, unknown>>;
|
|
89
|
+
readyState: 'starting' | 'ready' | 'failed';
|
|
90
|
+
lastUsedAt: number;
|
|
91
|
+
stderrTail: string;
|
|
92
|
+
idleTimer?: NodeJS.Timeout;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
type TestExecutor = (input: RemoteXpcHidInput) => Promise<RemoteXpcHidResult>;
|
|
96
|
+
let testExecutor: TestExecutor | undefined;
|
|
97
|
+
const workers = new Map<string, WorkerRecord>();
|
|
98
|
+
const warmups = new Map<string, Promise<void>>();
|
|
99
|
+
const workerGenerations = new Map<string, number>();
|
|
100
|
+
const warmupFailures = new Map<string, WarmupFailure>();
|
|
101
|
+
const endpointCache = new Map<string, EndpointCacheEntry>();
|
|
102
|
+
|
|
103
|
+
export function setRemoteXpcHidExecutorForTest(executor: TestExecutor | undefined): void {
|
|
104
|
+
testExecutor = executor;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function resetRemoteXpcHidForTest(): void {
|
|
108
|
+
testExecutor = undefined;
|
|
109
|
+
for (const worker of workers.values()) stopWorker(worker);
|
|
110
|
+
workers.clear();
|
|
111
|
+
warmups.clear();
|
|
112
|
+
workerGenerations.clear();
|
|
113
|
+
warmupFailures.clear();
|
|
114
|
+
endpointCache.clear();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function remoteXpcHidStatus(controllerHome: string, env: NodeJS.ProcessEnv = process.env): Record<string, unknown> {
|
|
118
|
+
const python = resolvePython(controllerHome, env);
|
|
119
|
+
return {
|
|
120
|
+
backend: 'remote-xpc-hid',
|
|
121
|
+
available: Boolean(python),
|
|
122
|
+
python,
|
|
123
|
+
toolchainVersion: TOOLCHAIN_VERSION,
|
|
124
|
+
transport: 'macos-trusted-coredevice-rsd',
|
|
125
|
+
runnerOwned: false,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function resolvePython(controllerHome: string, env: NodeJS.ProcessEnv): string | undefined {
|
|
130
|
+
const explicit = env.FORGE_IOS_HID_PYTHON?.trim();
|
|
131
|
+
const candidates = [
|
|
132
|
+
explicit,
|
|
133
|
+
join(controllerHome, 'toolchains', `pymobiledevice3-${TOOLCHAIN_VERSION}-py39`, 'bin', 'python'),
|
|
134
|
+
join(controllerHome, 'toolchains', `pymobiledevice3-${TOOLCHAIN_VERSION}`, 'bin', 'python'),
|
|
135
|
+
].filter((value): value is string => Boolean(value));
|
|
136
|
+
return candidates.find((candidate) => existsSync(candidate));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function workerSource(): string {
|
|
140
|
+
return String.raw`#!/usr/bin/env python3
|
|
141
|
+
import argparse
|
|
142
|
+
import asyncio
|
|
143
|
+
import json
|
|
144
|
+
import sys
|
|
145
|
+
import time
|
|
146
|
+
|
|
147
|
+
from pymobiledevice3.remote.remote_service_discovery import RemoteServiceDiscoveryService
|
|
148
|
+
from pymobiledevice3.remote.core_device.pasteboard_service import PasteboardService
|
|
149
|
+
from pymobiledevice3.remote.core_device.hid_service import (
|
|
150
|
+
ASCII_TO_HID,
|
|
151
|
+
DIGITIZER_SURFACE_MAIN_TOUCHSCREEN,
|
|
152
|
+
KEY_BACKSPACE,
|
|
153
|
+
KEY_LEFT_GUI,
|
|
154
|
+
KEY_LEFT_SHIFT,
|
|
155
|
+
TOUCHSCREEN_STATE_CONTACT,
|
|
156
|
+
TOUCHSCREEN_STATE_RELEASE,
|
|
157
|
+
UniversalHIDServiceService,
|
|
158
|
+
touch_session,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def keyboard_mapping(char):
|
|
163
|
+
if char == '\b':
|
|
164
|
+
return (KEY_BACKSPACE, False)
|
|
165
|
+
return ASCII_TO_HID.get(char)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class KeyboardChordError(RuntimeError):
|
|
169
|
+
def __init__(self, primary_error, release_error=None):
|
|
170
|
+
super().__init__(str(primary_error))
|
|
171
|
+
self.primary_error = primary_error
|
|
172
|
+
self.release_error = release_error
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class PasteboardOperationError(RuntimeError):
|
|
176
|
+
def __init__(self, primary_error, primary_phase, restore_error=None):
|
|
177
|
+
super().__init__(str(primary_error))
|
|
178
|
+
self.primary_error = primary_error
|
|
179
|
+
self.primary_phase = primary_phase
|
|
180
|
+
self.restore_error = restore_error
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
async def send_keyboard_chord(hid, service_id, usage_codes, hold_s=0.040, settle_s=0.040):
|
|
184
|
+
primary_error = None
|
|
185
|
+
release_error = None
|
|
186
|
+
try:
|
|
187
|
+
try:
|
|
188
|
+
await hid.send_keyboard(service_id, usage_codes)
|
|
189
|
+
await asyncio.sleep(hold_s)
|
|
190
|
+
except Exception as error:
|
|
191
|
+
primary_error = error
|
|
192
|
+
finally:
|
|
193
|
+
try:
|
|
194
|
+
await hid.send_keyboard(service_id, [])
|
|
195
|
+
except Exception as error:
|
|
196
|
+
release_error = error
|
|
197
|
+
if primary_error is not None or release_error is not None:
|
|
198
|
+
raise KeyboardChordError(primary_error or release_error, release_error) from (primary_error or release_error)
|
|
199
|
+
await asyncio.sleep(settle_s)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def normalized_point(x, y, width, height):
|
|
203
|
+
if width <= 1 or height <= 1:
|
|
204
|
+
raise ValueError('display dimensions must be > 1')
|
|
205
|
+
nx = max(0, min(65535, int(x * 65535 / (width - 1))))
|
|
206
|
+
ny = max(0, min(65535, int(y * 65535 / (height - 1))))
|
|
207
|
+
return nx, ny
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def response(request_id, ok, result=None, error=None, details=None):
|
|
211
|
+
payload = {'id': request_id, 'ok': ok}
|
|
212
|
+
if result is not None:
|
|
213
|
+
payload['result'] = result
|
|
214
|
+
if error is not None:
|
|
215
|
+
payload['error'] = error
|
|
216
|
+
if details is not None:
|
|
217
|
+
payload['details'] = details
|
|
218
|
+
print(json.dumps(payload, ensure_ascii=False), flush=True)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
async def main():
|
|
222
|
+
parser = argparse.ArgumentParser()
|
|
223
|
+
parser.add_argument('--host', required=True)
|
|
224
|
+
parser.add_argument('--port', required=True, type=int)
|
|
225
|
+
args = parser.parse_args()
|
|
226
|
+
async with RemoteServiceDiscoveryService((args.host, args.port)) as rsd:
|
|
227
|
+
async with touch_session(rsd) as hid:
|
|
228
|
+
connected = await hid.list_connected_services()
|
|
229
|
+
service_rows = connected.get('connectedServices', []) if isinstance(connected, dict) else []
|
|
230
|
+
service_ids = [row.get('_ServiceID') for row in service_rows if isinstance(row, dict)]
|
|
231
|
+
rsd_services = sorted((getattr(rsd, 'peer_info', {}) or {}).get('Services', {}).keys())
|
|
232
|
+
direct_keyboard_service = None
|
|
233
|
+
direct_keyboard_product = None
|
|
234
|
+
for row in service_rows:
|
|
235
|
+
if not isinstance(row, dict):
|
|
236
|
+
continue
|
|
237
|
+
service_id = row.get('_ServiceID')
|
|
238
|
+
if not isinstance(service_id, int) or service_id <= 0:
|
|
239
|
+
continue
|
|
240
|
+
device_hint = str(row.get('DeviceTypeHint') or '').lower()
|
|
241
|
+
product = str(row.get('Product') or '')
|
|
242
|
+
if device_hint == 'keyboard' or 'keyboard' in product.lower():
|
|
243
|
+
direct_keyboard_service = service_id
|
|
244
|
+
direct_keyboard_product = product or None
|
|
245
|
+
break
|
|
246
|
+
print(json.dumps({
|
|
247
|
+
'ready': True,
|
|
248
|
+
'serviceIds': service_ids,
|
|
249
|
+
'keyboardReady': direct_keyboard_service is not None,
|
|
250
|
+
'keyboardReused': direct_keyboard_service is not None,
|
|
251
|
+
'keyboardSource': 'connected_coredevice' if direct_keyboard_service is not None else 'none',
|
|
252
|
+
'keyboardProduct': direct_keyboard_product,
|
|
253
|
+
'modifierKeyboardSource': 'connected_coredevice' if direct_keyboard_service is not None else 'none',
|
|
254
|
+
'pasteboardAvailable': 'com.apple.coredevice.pasteboardservice' in rsd_services,
|
|
255
|
+
'pasteboardTransport': 'independent_rsd',
|
|
256
|
+
}), flush=True)
|
|
257
|
+
while True:
|
|
258
|
+
line = await asyncio.to_thread(sys.stdin.readline)
|
|
259
|
+
if not line:
|
|
260
|
+
return
|
|
261
|
+
request = None
|
|
262
|
+
phase = 'request_decode'
|
|
263
|
+
mutation_dispatched = False
|
|
264
|
+
try:
|
|
265
|
+
request = json.loads(line)
|
|
266
|
+
request_id = str(request.get('id', ''))
|
|
267
|
+
action = request.get('action')
|
|
268
|
+
request_started = time.perf_counter()
|
|
269
|
+
hid_started = time.perf_counter()
|
|
270
|
+
if action == 'tap':
|
|
271
|
+
phase = 'hid_prepare_tap'
|
|
272
|
+
nx, ny = normalized_point(int(request['x']), int(request['y']), int(request['width']), int(request['height']))
|
|
273
|
+
phase = 'hid_tap_contact'
|
|
274
|
+
mutation_dispatched = True
|
|
275
|
+
await hid.send_touchscreen(TOUCHSCREEN_STATE_CONTACT, nx, ny, DIGITIZER_SURFACE_MAIN_TOUCHSCREEN)
|
|
276
|
+
await asyncio.sleep(0.055)
|
|
277
|
+
await hid.send_touchscreen(TOUCHSCREEN_STATE_RELEASE, nx, ny, DIGITIZER_SURFACE_MAIN_TOUCHSCREEN)
|
|
278
|
+
result = {'action': 'tap', 'hid': [nx, ny]}
|
|
279
|
+
elif action == 'swipe':
|
|
280
|
+
x, y = int(request['x']), int(request['y'])
|
|
281
|
+
x2, y2 = int(request['x2']), int(request['y2'])
|
|
282
|
+
width, height = int(request['width']), int(request['height'])
|
|
283
|
+
duration_ms = max(80, min(int(request.get('durationMs', 250)), 3000))
|
|
284
|
+
duration = duration_ms / 1000.0
|
|
285
|
+
steps = max(4, min(60, round(duration * 60)))
|
|
286
|
+
phase = 'hid_swipe_contact'
|
|
287
|
+
mutation_dispatched = True
|
|
288
|
+
for index in range(steps + 1):
|
|
289
|
+
ratio = index / steps
|
|
290
|
+
px = round(x + (x2 - x) * ratio)
|
|
291
|
+
py = round(y + (y2 - y) * ratio)
|
|
292
|
+
nx, ny = normalized_point(px, py, width, height)
|
|
293
|
+
await hid.send_touchscreen(TOUCHSCREEN_STATE_CONTACT, nx, ny, DIGITIZER_SURFACE_MAIN_TOUCHSCREEN)
|
|
294
|
+
await asyncio.sleep(duration / steps)
|
|
295
|
+
nx2, ny2 = normalized_point(x2, y2, width, height)
|
|
296
|
+
await hid.send_touchscreen(TOUCHSCREEN_STATE_RELEASE, nx2, ny2, DIGITIZER_SURFACE_MAIN_TOUCHSCREEN)
|
|
297
|
+
result = {'action': 'swipe', 'durationMs': duration_ms}
|
|
298
|
+
elif action == 'type':
|
|
299
|
+
text = str(request.get('text', ''))
|
|
300
|
+
text_mode = str(request.get('textMode', 'auto'))
|
|
301
|
+
if text_mode not in ('auto', 'keys', 'pasteboard'):
|
|
302
|
+
raise ValueError('unsupported textMode')
|
|
303
|
+
key_supported = all(keyboard_mapping(char) is not None for char in text)
|
|
304
|
+
replace_existing = bool(request.get('replaceExisting', False))
|
|
305
|
+
pasteboard_available = 'com.apple.coredevice.pasteboardservice' in rsd_services
|
|
306
|
+
use_pasteboard = text_mode == 'pasteboard' or (text_mode == 'auto' and (not key_supported or len(text) >= 32))
|
|
307
|
+
if use_pasteboard and not pasteboard_available:
|
|
308
|
+
if text_mode == 'auto' and key_supported:
|
|
309
|
+
use_pasteboard = False
|
|
310
|
+
else:
|
|
311
|
+
raise RuntimeError('CoreDevice pasteboard service is unavailable for Unicode/pasteboard text input')
|
|
312
|
+
if text_mode == 'keys' and not key_supported:
|
|
313
|
+
raise ValueError('unsupported HID character in keys mode')
|
|
314
|
+
|
|
315
|
+
if direct_keyboard_service is None:
|
|
316
|
+
raise RuntimeError('Connected CoreDevice keyboard service is unavailable')
|
|
317
|
+
|
|
318
|
+
async def dispatch_text(keyboard_hid, keyboard_service, keyboard_source, keyboard_create_ms):
|
|
319
|
+
nonlocal phase, mutation_dispatched
|
|
320
|
+
if use_pasteboard:
|
|
321
|
+
phase = 'pasteboard_connect'
|
|
322
|
+
pasteboard_restored = True
|
|
323
|
+
async with RemoteServiceDiscoveryService((args.host, args.port)) as pasteboard_rsd:
|
|
324
|
+
async with PasteboardService(pasteboard_rsd) as pasteboard:
|
|
325
|
+
phase = 'pasteboard_snapshot'
|
|
326
|
+
previous = await pasteboard.get()
|
|
327
|
+
previous_snapshot = previous.get('pasteboard') if isinstance(previous.get('pasteboard'), dict) else previous
|
|
328
|
+
previous_items = previous_snapshot.get('items') if isinstance(previous_snapshot, dict) else None
|
|
329
|
+
previous_source = previous_snapshot.get('sourceMetadata') if isinstance(previous_snapshot, dict) else None
|
|
330
|
+
if not isinstance(previous_items, list):
|
|
331
|
+
raise RuntimeError('CoreDevice pasteboard snapshot is not restorable; refusing temporary clipboard overwrite')
|
|
332
|
+
operation_error = None
|
|
333
|
+
operation_phase = None
|
|
334
|
+
restore_error = None
|
|
335
|
+
try:
|
|
336
|
+
phase = 'pasteboard_set_text'
|
|
337
|
+
mutation_dispatched = True
|
|
338
|
+
await pasteboard.set_text(text)
|
|
339
|
+
if replace_existing:
|
|
340
|
+
select_mapping = keyboard_mapping('a')
|
|
341
|
+
if select_mapping is None:
|
|
342
|
+
raise RuntimeError('HID A key mapping unavailable')
|
|
343
|
+
select_usage, _ = select_mapping
|
|
344
|
+
phase = 'hid_keyboard_select_all'
|
|
345
|
+
await send_keyboard_chord(keyboard_hid, keyboard_service, [select_usage, KEY_LEFT_GUI])
|
|
346
|
+
paste_mapping = keyboard_mapping('v')
|
|
347
|
+
if paste_mapping is None:
|
|
348
|
+
raise RuntimeError('HID V key mapping unavailable')
|
|
349
|
+
paste_usage, _ = paste_mapping
|
|
350
|
+
phase = 'hid_keyboard_paste'
|
|
351
|
+
await send_keyboard_chord(keyboard_hid, keyboard_service, [paste_usage, KEY_LEFT_GUI], settle_s=0.120)
|
|
352
|
+
except Exception as error:
|
|
353
|
+
operation_error = error
|
|
354
|
+
operation_phase = phase
|
|
355
|
+
finally:
|
|
356
|
+
phase = 'pasteboard_restore'
|
|
357
|
+
try:
|
|
358
|
+
await pasteboard.set(previous_items, source_metadata=previous_source if isinstance(previous_source, dict) else None)
|
|
359
|
+
except Exception as error:
|
|
360
|
+
pasteboard_restored = False
|
|
361
|
+
restore_error = error
|
|
362
|
+
if operation_error is not None or restore_error is not None:
|
|
363
|
+
primary_error = operation_error or restore_error
|
|
364
|
+
primary_phase = operation_phase or 'pasteboard_restore'
|
|
365
|
+
raise PasteboardOperationError(primary_error, primary_phase, restore_error) from primary_error
|
|
366
|
+
return {
|
|
367
|
+
'action': 'type',
|
|
368
|
+
'length': len(text),
|
|
369
|
+
'inputMode': 'pasteboard',
|
|
370
|
+
'replaceExisting': replace_existing,
|
|
371
|
+
'keyboardSource': keyboard_source,
|
|
372
|
+
'keyboardCreateMs': round(keyboard_create_ms, 2),
|
|
373
|
+
'pasteboardTransport': 'independent_rsd',
|
|
374
|
+
'pasteboardRestored': pasteboard_restored,
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if replace_existing:
|
|
378
|
+
select_mapping = keyboard_mapping('a')
|
|
379
|
+
if select_mapping is None:
|
|
380
|
+
raise RuntimeError('HID A key mapping unavailable')
|
|
381
|
+
select_usage, _ = select_mapping
|
|
382
|
+
phase = 'hid_keyboard_select_all'
|
|
383
|
+
mutation_dispatched = True
|
|
384
|
+
await send_keyboard_chord(keyboard_hid, keyboard_service, [select_usage, KEY_LEFT_GUI])
|
|
385
|
+
if replace_existing and not text:
|
|
386
|
+
phase = 'hid_keyboard_delete_selection'
|
|
387
|
+
mutation_dispatched = True
|
|
388
|
+
await send_keyboard_chord(keyboard_hid, keyboard_service, [KEY_BACKSPACE])
|
|
389
|
+
else:
|
|
390
|
+
for char in text:
|
|
391
|
+
mapping = keyboard_mapping(char)
|
|
392
|
+
if mapping is None:
|
|
393
|
+
raise ValueError('unsupported HID character in keys mode')
|
|
394
|
+
usage, shifted = mapping
|
|
395
|
+
pressed = [usage]
|
|
396
|
+
if shifted:
|
|
397
|
+
pressed.append(KEY_LEFT_SHIFT)
|
|
398
|
+
phase = 'hid_keyboard_write'
|
|
399
|
+
mutation_dispatched = True
|
|
400
|
+
await send_keyboard_chord(keyboard_hid, keyboard_service, pressed, hold_s=0.018, settle_s=0.018)
|
|
401
|
+
await keyboard_hid.send_keyboard(keyboard_service, [])
|
|
402
|
+
return {
|
|
403
|
+
'action': 'type',
|
|
404
|
+
'length': len(text),
|
|
405
|
+
'inputMode': 'keys',
|
|
406
|
+
'replaceExisting': replace_existing,
|
|
407
|
+
'keyboardSource': keyboard_source,
|
|
408
|
+
'keyboardCreateMs': round(keyboard_create_ms, 2),
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
result = await dispatch_text(hid, direct_keyboard_service, 'connected_coredevice', 0.0)
|
|
412
|
+
else:
|
|
413
|
+
raise ValueError('unsupported action')
|
|
414
|
+
hid_ms = (time.perf_counter() - hid_started) * 1000.0
|
|
415
|
+
result['timings'] = {
|
|
416
|
+
'hidMs': round(hid_ms, 2),
|
|
417
|
+
'requestMs': round((time.perf_counter() - request_started) * 1000.0, 2),
|
|
418
|
+
}
|
|
419
|
+
result['mutationDispatched'] = mutation_dispatched
|
|
420
|
+
response(request_id, True, result)
|
|
421
|
+
except Exception as error:
|
|
422
|
+
if isinstance(error, PasteboardOperationError):
|
|
423
|
+
primary_error = error.primary_error
|
|
424
|
+
error_phase = error.primary_phase
|
|
425
|
+
release_error = None
|
|
426
|
+
pasteboard_restore_error = error.restore_error
|
|
427
|
+
else:
|
|
428
|
+
primary_error = error.primary_error if isinstance(error, KeyboardChordError) else error
|
|
429
|
+
error_phase = phase
|
|
430
|
+
release_error = error.release_error if isinstance(error, KeyboardChordError) else None
|
|
431
|
+
pasteboard_restore_error = None
|
|
432
|
+
error_details = {'phase': error_phase, 'mutationDispatched': mutation_dispatched}
|
|
433
|
+
if release_error is not None:
|
|
434
|
+
error_details['releaseFailure'] = f'{type(release_error).__name__}: {release_error}'[:512]
|
|
435
|
+
if pasteboard_restore_error is not None:
|
|
436
|
+
error_details['pasteboardRestoreFailure'] = f'{type(pasteboard_restore_error).__name__}: {pasteboard_restore_error}'[:512]
|
|
437
|
+
response(
|
|
438
|
+
str(request.get('id', '')) if isinstance(request, dict) else '',
|
|
439
|
+
False,
|
|
440
|
+
error=f'{type(primary_error).__name__}: {primary_error}',
|
|
441
|
+
details=error_details,
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
if __name__ == '__main__':
|
|
446
|
+
asyncio.run(main())
|
|
447
|
+
`;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export function remoteXpcHidWorkerSourceForTest(): string {
|
|
451
|
+
return workerSource();
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function deviceInputRoot(controllerHome: string): string {
|
|
455
|
+
const root = join(controllerHome, 'runtime', 'device-input');
|
|
456
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
457
|
+
return root;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function materializeWorker(controllerHome: string): string {
|
|
461
|
+
const source = workerSource();
|
|
462
|
+
const hash = createHash('sha256').update(source).digest('hex').slice(0, 16);
|
|
463
|
+
const path = join(deviceInputRoot(controllerHome), `remote-xpc-hid-${hash}.py`);
|
|
464
|
+
if (!existsSync(path)) writeFileSync(path, source, { encoding: 'utf8', mode: 0o700 });
|
|
465
|
+
return path;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function persistentEndpointPath(controllerHome: string, udid: string): string {
|
|
469
|
+
const identity = createHash('sha256').update(udid).digest('hex').slice(0, 24);
|
|
470
|
+
return join(deviceInputRoot(controllerHome), `rsd-endpoint-${identity}.json`);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function validEndpoint(value: unknown): RsdEndpoint | undefined {
|
|
474
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
|
475
|
+
const endpoint = value as Record<string, unknown>;
|
|
476
|
+
if (typeof endpoint.host !== 'string' || !/^[0-9a-fA-F:]+$/.test(endpoint.host)) return undefined;
|
|
477
|
+
if (typeof endpoint.port !== 'number' || !Number.isInteger(endpoint.port) || endpoint.port <= 0 || endpoint.port > 65_535) return undefined;
|
|
478
|
+
return { host: endpoint.host, port: endpoint.port };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function readPersistentEndpoint(controllerHome: string, udid: string, now = Date.now()): RsdEndpoint | undefined {
|
|
482
|
+
const path = persistentEndpointPath(controllerHome, udid);
|
|
483
|
+
if (!existsSync(path)) return undefined;
|
|
484
|
+
try {
|
|
485
|
+
const record = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>;
|
|
486
|
+
const observedAt = typeof record.observedAt === 'number' ? record.observedAt : Number.NaN;
|
|
487
|
+
const ageMs = now - observedAt;
|
|
488
|
+
const endpoint = record.schemaVersion === 1 ? validEndpoint(record.endpoint) : undefined;
|
|
489
|
+
if (!endpoint || !Number.isFinite(ageMs) || ageMs < -5_000 || ageMs > RSD_ENDPOINT_PERSISTED_TTL_MS) {
|
|
490
|
+
try { unlinkSync(path); } catch {}
|
|
491
|
+
return undefined;
|
|
492
|
+
}
|
|
493
|
+
return endpoint;
|
|
494
|
+
} catch {
|
|
495
|
+
try { unlinkSync(path); } catch {}
|
|
496
|
+
return undefined;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function rememberPersistentEndpoint(
|
|
501
|
+
controllerHome: string,
|
|
502
|
+
udid: string,
|
|
503
|
+
endpoint: RsdEndpoint,
|
|
504
|
+
observedAt = Date.now(),
|
|
505
|
+
): void {
|
|
506
|
+
const path = persistentEndpointPath(controllerHome, udid);
|
|
507
|
+
const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
508
|
+
try {
|
|
509
|
+
writeFileSync(tmp, JSON.stringify({ schemaVersion: 1, observedAt, endpoint }), { encoding: 'utf8', mode: 0o600 });
|
|
510
|
+
renameSync(tmp, path);
|
|
511
|
+
} catch {
|
|
512
|
+
try { unlinkSync(tmp); } catch {}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
export function remoteXpcHidPersistentEndpointCacheForTest(
|
|
517
|
+
controllerHome: string,
|
|
518
|
+
udid: string,
|
|
519
|
+
endpoint?: RsdEndpoint,
|
|
520
|
+
ageMs = 0,
|
|
521
|
+
): RsdEndpoint | undefined {
|
|
522
|
+
if (endpoint) rememberPersistentEndpoint(controllerHome, udid, endpoint, Date.now() - Math.max(0, ageMs));
|
|
523
|
+
return readPersistentEndpoint(controllerHome, udid);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function persistedEndpointActiveInIfconfig(host: string, output: string): boolean | undefined {
|
|
527
|
+
const normalizedHost = host.toLowerCase();
|
|
528
|
+
const remoteMatch = normalizedHost.match(/^([0-9a-f:]+)::1$/);
|
|
529
|
+
if (!remoteMatch) return undefined;
|
|
530
|
+
const expectedLocal = `${remoteMatch[1]}::2`;
|
|
531
|
+
let inUtun = false;
|
|
532
|
+
let sawUtun = false;
|
|
533
|
+
for (const line of output.split(/\r?\n/)) {
|
|
534
|
+
const interfaceMatch = line.match(/^([A-Za-z0-9._-]+):/);
|
|
535
|
+
if (interfaceMatch) {
|
|
536
|
+
inUtun = interfaceMatch[1].startsWith('utun');
|
|
537
|
+
if (inUtun) sawUtun = true;
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
if (!inUtun) continue;
|
|
541
|
+
const ipv6 = line.match(/\binet6\s+([0-9a-fA-F:]+)\s+prefixlen\s+(\d+)/);
|
|
542
|
+
if (!ipv6 || Number(ipv6[2]) !== 64) continue;
|
|
543
|
+
if (ipv6[1].toLowerCase() === expectedLocal) return true;
|
|
544
|
+
}
|
|
545
|
+
return sawUtun ? false : undefined;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function persistedEndpointActive(host: string): boolean | undefined {
|
|
549
|
+
const result = spawnSync('/sbin/ifconfig', [], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2_000 });
|
|
550
|
+
if (result.status !== 0 || result.error || typeof result.stdout !== 'string') return undefined;
|
|
551
|
+
return persistedEndpointActiveInIfconfig(host, result.stdout);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function invalidatePersistentEndpoint(controllerHome: string, udid: string): void {
|
|
555
|
+
try { unlinkSync(persistentEndpointPath(controllerHome, udid)); } catch {}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export function remoteXpcHidPersistedEndpointActiveForTest(host: string, ifconfigOutput: string): boolean | undefined {
|
|
559
|
+
return persistedEndpointActiveInIfconfig(host, ifconfigOutput);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
export function parseMacOSTrustedRsdEndpoints(output: string, udid: string): RsdEndpoint[] {
|
|
563
|
+
const endpoints: RsdEndpoint[] = [];
|
|
564
|
+
let host: string | undefined;
|
|
565
|
+
let latestHost: string | undefined;
|
|
566
|
+
for (const line of output.split(/\r?\n/)) {
|
|
567
|
+
if (!line.includes(udid)) continue;
|
|
568
|
+
const tunnel = line.match(/remote\s+([0-9a-fA-F:]+)\s*$/);
|
|
569
|
+
if (tunnel) {
|
|
570
|
+
host = tunnel[1];
|
|
571
|
+
latestHost = host;
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
const portMatch = line.match(/Creating RSD backend client device for server port\s+(\d+)/);
|
|
575
|
+
if (portMatch && host) {
|
|
576
|
+
const port = Number(portMatch[1]);
|
|
577
|
+
if (Number.isInteger(port) && port > 0 && port <= 65_535) endpoints.push({ host, port });
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
if (!latestHost) return [];
|
|
581
|
+
const unique = new Map<string, RsdEndpoint>();
|
|
582
|
+
for (const endpoint of endpoints) {
|
|
583
|
+
if (endpoint.host === latestHost) unique.set(`${endpoint.host}:${endpoint.port}`, endpoint);
|
|
584
|
+
}
|
|
585
|
+
return [...unique.values()].slice(-3).reverse();
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function cachedEndpoints(udid: string, now = Date.now()): RsdEndpoint[] | undefined {
|
|
589
|
+
const cached = endpointCache.get(udid);
|
|
590
|
+
if (!cached) return undefined;
|
|
591
|
+
if (now - cached.observedAt > RSD_ENDPOINT_CACHE_TTL_MS) {
|
|
592
|
+
endpointCache.delete(udid);
|
|
593
|
+
return undefined;
|
|
594
|
+
}
|
|
595
|
+
return cached.endpoints.map((endpoint) => ({ ...endpoint }));
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function rememberEndpoints(udid: string, endpoints: RsdEndpoint[], now = Date.now()): void {
|
|
599
|
+
endpointCache.set(udid, { observedAt: now, endpoints: endpoints.map((endpoint) => ({ ...endpoint })) });
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
export function remoteXpcHidEndpointCacheForTest(
|
|
603
|
+
udid: string,
|
|
604
|
+
endpoints: RsdEndpoint[],
|
|
605
|
+
ageMs = 0,
|
|
606
|
+
): RsdEndpoint[] | undefined {
|
|
607
|
+
rememberEndpoints(udid, endpoints, Date.now() - Math.max(0, ageMs));
|
|
608
|
+
return cachedEndpoints(udid);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function endpointIdentity(endpoint: RsdEndpoint): string {
|
|
612
|
+
return `${endpoint.host}:${endpoint.port}`;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function novelEndpoints(previous: readonly RsdEndpoint[], refreshed: readonly RsdEndpoint[]): RsdEndpoint[] {
|
|
616
|
+
const attempted = new Set(previous.map(endpointIdentity));
|
|
617
|
+
return refreshed.filter((endpoint) => !attempted.has(endpointIdentity(endpoint)));
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
export function remoteXpcHidNovelEndpointsForTest(
|
|
621
|
+
previous: readonly RsdEndpoint[],
|
|
622
|
+
refreshed: readonly RsdEndpoint[],
|
|
623
|
+
): RsdEndpoint[] {
|
|
624
|
+
return novelEndpoints(previous, refreshed);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function trustedTunnelLogArgs(udid: string): string[] {
|
|
628
|
+
if (!/^[A-Za-z0-9-]+$/.test(udid)) {
|
|
629
|
+
throw new AssistantPluginError('IOS_HID_DEVICE_ID_INVALID', 'The physical iPhone UDID cannot be used for trusted-tunnel discovery.', { retryable: false });
|
|
630
|
+
}
|
|
631
|
+
const predicate = `process == "remotepairingd" AND eventMessage CONTAINS "${udid}" AND (eventMessage CONTAINS "Tunnel established" OR eventMessage CONTAINS "Creating RSD backend client device for server port")`;
|
|
632
|
+
return ['show', '--last', '12h', '--style', 'compact', '--predicate', predicate];
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
async function discoverEndpoints(udid: string, forceRefresh = false): Promise<RsdEndpoint[]> {
|
|
636
|
+
if (!forceRefresh) {
|
|
637
|
+
const cached = cachedEndpoints(udid);
|
|
638
|
+
if (cached?.length) return cached;
|
|
639
|
+
}
|
|
640
|
+
const args = trustedTunnelLogArgs(udid);
|
|
641
|
+
return await new Promise<RsdEndpoint[]>((resolve, reject) => {
|
|
642
|
+
const child = spawn('/usr/bin/log', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
643
|
+
let stdout = '';
|
|
644
|
+
let stderr = '';
|
|
645
|
+
let settled = false;
|
|
646
|
+
const timer = setTimeout(() => {
|
|
647
|
+
try { child.kill('SIGTERM'); } catch {}
|
|
648
|
+
finish(new Error('macOS trusted-tunnel discovery timed out after 15000ms.'));
|
|
649
|
+
}, 15_000);
|
|
650
|
+
const finish = (error?: Error) => {
|
|
651
|
+
if (settled) return;
|
|
652
|
+
settled = true;
|
|
653
|
+
clearTimeout(timer);
|
|
654
|
+
if (error) {
|
|
655
|
+
reject(new AssistantPluginError('IOS_HID_RSD_DISCOVERY_FAILED', error.message, { retryable: true }));
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
const endpoints = parseMacOSTrustedRsdEndpoints(stdout, udid);
|
|
659
|
+
if (endpoints.length === 0) {
|
|
660
|
+
reject(new AssistantPluginError('IOS_HID_RSD_UNAVAILABLE', 'No current macOS trusted CoreDevice RSD endpoint was found for the selected iPhone.', {
|
|
661
|
+
retryable: true,
|
|
662
|
+
details: { udid },
|
|
663
|
+
}));
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
rememberEndpoints(udid, endpoints);
|
|
667
|
+
resolve(endpoints);
|
|
668
|
+
};
|
|
669
|
+
child.stdout.on('data', (chunk) => {
|
|
670
|
+
stdout = `${stdout}${String(chunk)}`.slice(-2 * 1024 * 1024);
|
|
671
|
+
});
|
|
672
|
+
child.stderr.on('data', (chunk) => {
|
|
673
|
+
stderr = `${stderr}${String(chunk)}`.slice(-MAX_STDERR);
|
|
674
|
+
});
|
|
675
|
+
child.on('error', (error) => finish(error));
|
|
676
|
+
child.on('close', (code) => {
|
|
677
|
+
if (code === 0) finish();
|
|
678
|
+
else finish(new Error(stderr.trim() || `macOS trusted-tunnel discovery exited with code ${code}.`));
|
|
679
|
+
});
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function stopWorker(worker: WorkerRecord): void {
|
|
684
|
+
if (worker.idleTimer) clearTimeout(worker.idleTimer);
|
|
685
|
+
for (const pending of worker.pending.values()) {
|
|
686
|
+
clearTimeout(pending.timer);
|
|
687
|
+
pending.reject(new Error('RemoteXPC HID worker stopped.'));
|
|
688
|
+
}
|
|
689
|
+
worker.pending.clear();
|
|
690
|
+
try { worker.process.stdin.end(); } catch {}
|
|
691
|
+
try { worker.process.kill('SIGTERM'); } catch {}
|
|
692
|
+
if (workers.get(worker.key) === worker) workers.delete(worker.key);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function scheduleIdleStop(worker: WorkerRecord): void {
|
|
696
|
+
if (worker.idleTimer) clearTimeout(worker.idleTimer);
|
|
697
|
+
worker.idleTimer = setTimeout(() => stopWorker(worker), WORKER_IDLE_MS);
|
|
698
|
+
worker.idleTimer.unref?.();
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function workerGeneration(deviceIdentifier: string): number {
|
|
702
|
+
return workerGenerations.get(deviceIdentifier) ?? 0;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function warmupFailureError(deviceIdentifier: string, failure: WarmupFailure): AssistantPluginError {
|
|
706
|
+
return new AssistantPluginError(
|
|
707
|
+
'IOS_HID_INPUT_NOT_SENT',
|
|
708
|
+
`RemoteXPC HID warmup failed before any input was sent: ${failure.message}`,
|
|
709
|
+
{
|
|
710
|
+
retryable: true,
|
|
711
|
+
details: {
|
|
712
|
+
deviceIdentifier,
|
|
713
|
+
mutationDispatched: false,
|
|
714
|
+
phase: 'worker_warmup',
|
|
715
|
+
warmupFailureAgeMs: Math.max(0, Date.now() - failure.recordedAt),
|
|
716
|
+
},
|
|
717
|
+
},
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
export function remoteXpcHidWarmupFailureErrorForTest(deviceIdentifier: string, message: string): AssistantPluginError {
|
|
722
|
+
return warmupFailureError(deviceIdentifier, {
|
|
723
|
+
generation: workerGeneration(deviceIdentifier),
|
|
724
|
+
recordedAt: Date.now(),
|
|
725
|
+
message: message.slice(0, 1024),
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
export function stopRemoteXpcHidForDevice(deviceIdentifier: string): Record<string, unknown> {
|
|
730
|
+
const worker = workers.get(deviceIdentifier);
|
|
731
|
+
const cancelledWarmup = warmups.has(deviceIdentifier);
|
|
732
|
+
workerGenerations.set(deviceIdentifier, workerGeneration(deviceIdentifier) + 1);
|
|
733
|
+
warmups.delete(deviceIdentifier);
|
|
734
|
+
warmupFailures.delete(deviceIdentifier);
|
|
735
|
+
if (worker) stopWorker(worker);
|
|
736
|
+
return {
|
|
737
|
+
backend: 'remote-xpc-hid',
|
|
738
|
+
state: 'stopped',
|
|
739
|
+
workerStopped: Boolean(worker),
|
|
740
|
+
warmupCancelled: cancelledWarmup,
|
|
741
|
+
runnerOwned: false,
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function startWorker(input: Pick<RemoteXpcHidInput, 'controllerHome' | 'deviceIdentifier'>, endpoint: RsdEndpoint): WorkerRecord {
|
|
746
|
+
const python = resolvePython(input.controllerHome, process.env);
|
|
747
|
+
if (!python) {
|
|
748
|
+
throw new AssistantPluginError('IOS_HID_TOOLCHAIN_MISSING', `The Controller-owned pymobiledevice3 ${TOOLCHAIN_VERSION} toolchain is unavailable.`, {
|
|
749
|
+
retryable: false,
|
|
750
|
+
details: { expected: join(input.controllerHome, 'toolchains', `pymobiledevice3-${TOOLCHAIN_VERSION}-py39`, 'bin', 'python') },
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
const script = materializeWorker(input.controllerHome);
|
|
754
|
+
const workerPath = dirname(python);
|
|
755
|
+
const child = spawn(python, [
|
|
756
|
+
'-u', script,
|
|
757
|
+
'--host', endpoint.host,
|
|
758
|
+
'--port', String(endpoint.port),
|
|
759
|
+
], {
|
|
760
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
761
|
+
env: {
|
|
762
|
+
PATH: `${workerPath}:/usr/bin:/bin:/usr/sbin:/sbin`,
|
|
763
|
+
...(process.env.HOME ? { HOME: process.env.HOME } : {}),
|
|
764
|
+
...(process.env.TMPDIR ? { TMPDIR: process.env.TMPDIR } : {}),
|
|
765
|
+
PYTHONUNBUFFERED: '1',
|
|
766
|
+
PYTHONDONTWRITEBYTECODE: '1',
|
|
767
|
+
},
|
|
768
|
+
});
|
|
769
|
+
const key = input.deviceIdentifier;
|
|
770
|
+
const pending = new Map<string, PendingRequest>();
|
|
771
|
+
let readyResolve!: (value: Record<string, unknown>) => void;
|
|
772
|
+
let readyReject!: (error: Error) => void;
|
|
773
|
+
const ready = new Promise<Record<string, unknown>>((resolve, reject) => {
|
|
774
|
+
readyResolve = resolve;
|
|
775
|
+
readyReject = reject;
|
|
776
|
+
});
|
|
777
|
+
const worker: WorkerRecord = {
|
|
778
|
+
key, endpoint, process: child, pending, ready, readyState: 'starting', lastUsedAt: Date.now(), stderrTail: '',
|
|
779
|
+
};
|
|
780
|
+
workers.set(key, worker);
|
|
781
|
+
|
|
782
|
+
const startupTimer = setTimeout(() => {
|
|
783
|
+
readyReject(new Error(`RemoteXPC HID worker startup timed out after ${WORKER_STARTUP_MS}ms.`));
|
|
784
|
+
stopWorker(worker);
|
|
785
|
+
}, WORKER_STARTUP_MS);
|
|
786
|
+
|
|
787
|
+
const stdout = createInterface({ input: child.stdout });
|
|
788
|
+
stdout.on('line', (line) => {
|
|
789
|
+
let payload: Record<string, unknown>;
|
|
790
|
+
try { payload = JSON.parse(line) as Record<string, unknown>; } catch { return; }
|
|
791
|
+
if (payload.ready === true) {
|
|
792
|
+
clearTimeout(startupTimer);
|
|
793
|
+
worker.readyState = 'ready';
|
|
794
|
+
readyResolve(payload);
|
|
795
|
+
scheduleIdleStop(worker);
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
const id = typeof payload.id === 'string' ? payload.id : '';
|
|
799
|
+
const request = pending.get(id);
|
|
800
|
+
if (!request) return;
|
|
801
|
+
pending.delete(id);
|
|
802
|
+
clearTimeout(request.timer);
|
|
803
|
+
if (payload.ok === true && payload.result && typeof payload.result === 'object' && !Array.isArray(payload.result)) {
|
|
804
|
+
request.resolve(payload.result as Record<string, unknown>);
|
|
805
|
+
} else {
|
|
806
|
+
const details = payload.details && typeof payload.details === 'object' && !Array.isArray(payload.details)
|
|
807
|
+
? payload.details as Record<string, unknown>
|
|
808
|
+
: {};
|
|
809
|
+
request.reject(new WorkerRequestError(
|
|
810
|
+
typeof payload.error === 'string' ? payload.error : 'RemoteXPC HID request failed.',
|
|
811
|
+
{
|
|
812
|
+
phase: typeof details.phase === 'string' ? details.phase : undefined,
|
|
813
|
+
mutationDispatched: typeof details.mutationDispatched === 'boolean' ? details.mutationDispatched : undefined,
|
|
814
|
+
},
|
|
815
|
+
));
|
|
816
|
+
}
|
|
817
|
+
});
|
|
818
|
+
child.stderr.on('data', (chunk) => {
|
|
819
|
+
worker.stderrTail = `${worker.stderrTail}${String(chunk)}`.slice(-MAX_STDERR);
|
|
820
|
+
});
|
|
821
|
+
child.on('exit', (code, signal) => {
|
|
822
|
+
clearTimeout(startupTimer);
|
|
823
|
+
worker.readyState = 'failed';
|
|
824
|
+
const message = `RemoteXPC HID worker exited (${code ?? signal ?? 'unknown'}). ${worker.stderrTail}`.trim();
|
|
825
|
+
readyReject(new Error(message));
|
|
826
|
+
for (const request of pending.values()) {
|
|
827
|
+
clearTimeout(request.timer);
|
|
828
|
+
request.reject(new Error(message));
|
|
829
|
+
}
|
|
830
|
+
pending.clear();
|
|
831
|
+
if (workers.get(key) === worker) workers.delete(key);
|
|
832
|
+
});
|
|
833
|
+
child.on('error', (error) => {
|
|
834
|
+
clearTimeout(startupTimer);
|
|
835
|
+
worker.readyState = 'failed';
|
|
836
|
+
readyReject(error);
|
|
837
|
+
});
|
|
838
|
+
return worker;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
async function workerRequest(worker: WorkerRecord, input: RemoteXpcHidInput): Promise<{
|
|
842
|
+
result: Record<string, unknown>;
|
|
843
|
+
workerReadyMs: number;
|
|
844
|
+
requestMs: number;
|
|
845
|
+
}> {
|
|
846
|
+
const readyStartedAt = performance.now();
|
|
847
|
+
await worker.ready;
|
|
848
|
+
const workerReadyMs = Math.round((performance.now() - readyStartedAt) * 100) / 100;
|
|
849
|
+
const id = randomUUID();
|
|
850
|
+
const payload: Record<string, unknown> = {
|
|
851
|
+
id,
|
|
852
|
+
action: input.action,
|
|
853
|
+
};
|
|
854
|
+
if (input.width !== undefined) payload.width = input.width;
|
|
855
|
+
if (input.height !== undefined) payload.height = input.height;
|
|
856
|
+
if (input.x !== undefined) payload.x = input.x;
|
|
857
|
+
if (input.y !== undefined) payload.y = input.y;
|
|
858
|
+
if (input.x2 !== undefined) payload.x2 = input.x2;
|
|
859
|
+
if (input.y2 !== undefined) payload.y2 = input.y2;
|
|
860
|
+
if (input.durationMs !== undefined) payload.durationMs = input.durationMs;
|
|
861
|
+
if (input.textMode !== undefined) payload.textMode = input.textMode;
|
|
862
|
+
if (input.replaceExisting !== undefined) payload.replaceExisting = input.replaceExisting;
|
|
863
|
+
if (input.text !== undefined) payload.text = input.text;
|
|
864
|
+
worker.lastUsedAt = Date.now();
|
|
865
|
+
scheduleIdleStop(worker);
|
|
866
|
+
const requestStartedAt = performance.now();
|
|
867
|
+
const result = await new Promise<Record<string, unknown>>((resolve, reject) => {
|
|
868
|
+
const timer = setTimeout(() => {
|
|
869
|
+
worker.pending.delete(id);
|
|
870
|
+
reject(new Error(`RemoteXPC HID request timed out after ${REQUEST_TIMEOUT_MS}ms.`));
|
|
871
|
+
}, REQUEST_TIMEOUT_MS);
|
|
872
|
+
worker.pending.set(id, { resolve, reject, timer });
|
|
873
|
+
worker.process.stdin.write(`${JSON.stringify(payload)}\n`, (error) => {
|
|
874
|
+
if (!error) return;
|
|
875
|
+
const request = worker.pending.get(id);
|
|
876
|
+
if (!request) return;
|
|
877
|
+
worker.pending.delete(id);
|
|
878
|
+
clearTimeout(request.timer);
|
|
879
|
+
reject(error);
|
|
880
|
+
});
|
|
881
|
+
});
|
|
882
|
+
return {
|
|
883
|
+
result,
|
|
884
|
+
workerReadyMs,
|
|
885
|
+
requestMs: Math.round((performance.now() - requestStartedAt) * 100) / 100,
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function validateInput(input: RemoteXpcHidInput): void {
|
|
890
|
+
if (!input.udid) throw new AssistantPluginError('IOS_HID_UDID_MISSING', 'RemoteXPC HID requires the physical iPhone hardware UDID.', { retryable: false });
|
|
891
|
+
const requiresDisplay = input.action === 'tap' || input.action === 'swipe';
|
|
892
|
+
if (requiresDisplay && (!Number.isFinite(input.width) || !Number.isFinite(input.height) || input.width! <= 1 || input.height! <= 1)) {
|
|
893
|
+
throw new AssistantPluginError('IOS_HID_DISPLAY_INVALID', 'RemoteXPC touch input requires current positive display pixel dimensions.', { retryable: true });
|
|
894
|
+
}
|
|
895
|
+
const points = input.action === 'tap'
|
|
896
|
+
? [[input.x, input.y, 'tap'] as const]
|
|
897
|
+
: input.action === 'swipe'
|
|
898
|
+
? [[input.x, input.y, 'from'] as const, [input.x2, input.y2, 'to'] as const]
|
|
899
|
+
: [];
|
|
900
|
+
for (const [x, y, label] of points) {
|
|
901
|
+
if (typeof x !== 'number' || !Number.isFinite(x) || typeof y !== 'number' || !Number.isFinite(y)) {
|
|
902
|
+
throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', `${label} coordinates must be finite display pixels.`, { retryable: false });
|
|
903
|
+
}
|
|
904
|
+
if (x < 0 || y < 0 || x > input.width! - 1 || y > input.height! - 1) {
|
|
905
|
+
throw new AssistantPluginError('IOS_HID_COORDINATE_OUT_OF_BOUNDS', `${label} coordinates are outside the current CoreDevice display.`, {
|
|
906
|
+
retryable: false,
|
|
907
|
+
details: { x, y, width: input.width, height: input.height },
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
if (input.action === 'type') {
|
|
912
|
+
const text = input.text ?? '';
|
|
913
|
+
if (text.length > MAX_TEXT_LENGTH) throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', `RemoteXPC HID text is limited to ${MAX_TEXT_LENGTH} characters.`, { retryable: false });
|
|
914
|
+
const textMode = input.textMode ?? 'auto';
|
|
915
|
+
if (!['auto', 'keys', 'pasteboard'].includes(textMode)) {
|
|
916
|
+
throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', `Unsupported RemoteXPC HID text mode: ${textMode}.`, { retryable: false });
|
|
917
|
+
}
|
|
918
|
+
if (textMode === 'keys' && [...text].some((character) => character.charCodeAt(0) > 0x7f)) {
|
|
919
|
+
throw new AssistantPluginError('IOS_HID_UNICODE_TEXT_UNSUPPORTED', 'RemoteXPC HID keys mode accepts ASCII only; use auto or pasteboard mode for Unicode text.', { retryable: false });
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
async function establishWorker(
|
|
925
|
+
input: Pick<RemoteXpcHidInput, 'controllerHome' | 'deviceIdentifier' | 'udid'>,
|
|
926
|
+
generation = workerGeneration(input.deviceIdentifier),
|
|
927
|
+
): Promise<void> {
|
|
928
|
+
if (workers.has(input.deviceIdentifier)) return;
|
|
929
|
+
const cached = cachedEndpoints(input.udid);
|
|
930
|
+
let persisted = cached?.length ? undefined : readPersistentEndpoint(input.controllerHome, input.udid);
|
|
931
|
+
if (persisted && persistedEndpointActive(persisted.host) === false) {
|
|
932
|
+
invalidatePersistentEndpoint(input.controllerHome, input.udid);
|
|
933
|
+
persisted = undefined;
|
|
934
|
+
}
|
|
935
|
+
const initialEndpoints = cached?.length
|
|
936
|
+
? cached
|
|
937
|
+
: persisted
|
|
938
|
+
? [persisted]
|
|
939
|
+
: await discoverEndpoints(input.udid, true);
|
|
940
|
+
if (workerGeneration(input.deviceIdentifier) !== generation) return;
|
|
941
|
+
|
|
942
|
+
let lastError: unknown;
|
|
943
|
+
let attemptedEndpointCount = 0;
|
|
944
|
+
const tryEndpoints = async (endpoints: readonly RsdEndpoint[]): Promise<'ready' | 'cancelled' | 'failed'> => {
|
|
945
|
+
for (const endpoint of endpoints) {
|
|
946
|
+
if (workerGeneration(input.deviceIdentifier) !== generation) return 'cancelled';
|
|
947
|
+
attemptedEndpointCount += 1;
|
|
948
|
+
const worker = startWorker(input, endpoint);
|
|
949
|
+
try {
|
|
950
|
+
await worker.ready;
|
|
951
|
+
if (workerGeneration(input.deviceIdentifier) !== generation) {
|
|
952
|
+
stopWorker(worker);
|
|
953
|
+
return 'cancelled';
|
|
954
|
+
}
|
|
955
|
+
warmupFailures.delete(input.deviceIdentifier);
|
|
956
|
+
rememberPersistentEndpoint(input.controllerHome, input.udid, endpoint);
|
|
957
|
+
return 'ready';
|
|
958
|
+
} catch (error) {
|
|
959
|
+
lastError = error;
|
|
960
|
+
stopWorker(worker);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
return 'failed';
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
const initialResult = await tryEndpoints(initialEndpoints);
|
|
967
|
+
if (initialResult !== 'failed') return;
|
|
968
|
+
|
|
969
|
+
let refreshedEndpointCount = 0;
|
|
970
|
+
let novelEndpointCount = 0;
|
|
971
|
+
if (cached?.length || persisted) {
|
|
972
|
+
endpointCache.delete(input.udid);
|
|
973
|
+
try {
|
|
974
|
+
const refreshed = await discoverEndpoints(input.udid, true);
|
|
975
|
+
refreshedEndpointCount = refreshed.length;
|
|
976
|
+
const novel = novelEndpoints(initialEndpoints, refreshed);
|
|
977
|
+
novelEndpointCount = novel.length;
|
|
978
|
+
if (novel.length > 0) {
|
|
979
|
+
const refreshedResult = await tryEndpoints(novel);
|
|
980
|
+
if (refreshedResult !== 'failed') return;
|
|
981
|
+
}
|
|
982
|
+
} catch (error) {
|
|
983
|
+
lastError = error;
|
|
984
|
+
}
|
|
985
|
+
} else {
|
|
986
|
+
endpointCache.delete(input.udid);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
throw new AssistantPluginError('IOS_HID_INPUT_FAILED', lastError instanceof Error ? lastError.message : 'RemoteXPC HID input failed on all discovered trusted tunnel endpoints.', {
|
|
990
|
+
retryable: true,
|
|
991
|
+
details: {
|
|
992
|
+
deviceIdentifier: input.deviceIdentifier,
|
|
993
|
+
endpointCount: attemptedEndpointCount,
|
|
994
|
+
initialEndpointCount: initialEndpoints.length,
|
|
995
|
+
cacheRefreshAttempted: Boolean(cached?.length || persisted),
|
|
996
|
+
persistentEndpointUsed: Boolean(persisted),
|
|
997
|
+
refreshedEndpointCount,
|
|
998
|
+
novelEndpointCount,
|
|
999
|
+
},
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
export function prewarmRemoteXpcHid(input: Pick<RemoteXpcHidInput, 'controllerHome' | 'deviceIdentifier' | 'udid'>): Record<string, unknown> {
|
|
1004
|
+
if (testExecutor) return { backend: 'remote-xpc-hid', state: 'test', runnerOwned: false };
|
|
1005
|
+
const worker = workers.get(input.deviceIdentifier);
|
|
1006
|
+
if (worker?.readyState === 'ready') return { backend: 'remote-xpc-hid', state: 'ready', runnerOwned: false, reusedWorker: true };
|
|
1007
|
+
if (worker || warmups.has(input.deviceIdentifier)) return { backend: 'remote-xpc-hid', state: 'warming', runnerOwned: false };
|
|
1008
|
+
if (!resolvePython(input.controllerHome, process.env)) {
|
|
1009
|
+
return { backend: 'remote-xpc-hid', state: 'unavailable', runnerOwned: false, reason: 'toolchain_missing' };
|
|
1010
|
+
}
|
|
1011
|
+
const generation = workerGeneration(input.deviceIdentifier);
|
|
1012
|
+
warmupFailures.delete(input.deviceIdentifier);
|
|
1013
|
+
let warmup!: Promise<void>;
|
|
1014
|
+
warmup = establishWorker(input, generation)
|
|
1015
|
+
.catch((error) => {
|
|
1016
|
+
if (workerGeneration(input.deviceIdentifier) === generation) {
|
|
1017
|
+
const message = (error instanceof Error ? error.message : String(error)).slice(0, 1024);
|
|
1018
|
+
warmupFailures.set(input.deviceIdentifier, { generation, recordedAt: Date.now(), message });
|
|
1019
|
+
}
|
|
1020
|
+
})
|
|
1021
|
+
.then(() => undefined)
|
|
1022
|
+
.finally(() => {
|
|
1023
|
+
if (warmups.get(input.deviceIdentifier) === warmup) warmups.delete(input.deviceIdentifier);
|
|
1024
|
+
});
|
|
1025
|
+
warmups.set(input.deviceIdentifier, warmup);
|
|
1026
|
+
return { backend: 'remote-xpc-hid', state: 'warming_started', runnerOwned: false };
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
export async function awaitRemoteXpcHidPrewarm(
|
|
1030
|
+
input: Pick<RemoteXpcHidInput, 'controllerHome' | 'deviceIdentifier' | 'udid'>,
|
|
1031
|
+
budgetMs = 4_000,
|
|
1032
|
+
): Promise<Record<string, unknown>> {
|
|
1033
|
+
const boundedBudgetMs = Math.max(0, Math.min(Math.round(budgetMs), 10_000));
|
|
1034
|
+
const initial = prewarmRemoteXpcHid(input);
|
|
1035
|
+
if (testExecutor) return { ...initial, waitMs: 0 };
|
|
1036
|
+
if (initial.state === 'ready' || initial.state === 'unavailable') return { ...initial, waitMs: 0 };
|
|
1037
|
+
const startedAt = performance.now();
|
|
1038
|
+
const current = workers.get(input.deviceIdentifier);
|
|
1039
|
+
const warmup = warmups.get(input.deviceIdentifier);
|
|
1040
|
+
const readiness = current
|
|
1041
|
+
? current.ready.then(() => undefined).catch(() => undefined)
|
|
1042
|
+
: warmup;
|
|
1043
|
+
if (readiness && boundedBudgetMs > 0) {
|
|
1044
|
+
let timer: NodeJS.Timeout | undefined;
|
|
1045
|
+
try {
|
|
1046
|
+
await Promise.race([
|
|
1047
|
+
readiness,
|
|
1048
|
+
new Promise<void>((resolve) => {
|
|
1049
|
+
timer = setTimeout(resolve, boundedBudgetMs);
|
|
1050
|
+
timer.unref?.();
|
|
1051
|
+
}),
|
|
1052
|
+
]);
|
|
1053
|
+
} finally {
|
|
1054
|
+
if (timer) clearTimeout(timer);
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
const waitMs = Math.round((performance.now() - startedAt) * 100) / 100;
|
|
1058
|
+
const worker = workers.get(input.deviceIdentifier);
|
|
1059
|
+
if (worker?.readyState === 'ready') {
|
|
1060
|
+
return { backend: 'remote-xpc-hid', state: 'ready', runnerOwned: false, reusedWorker: initial.state === 'ready', waitMs };
|
|
1061
|
+
}
|
|
1062
|
+
const failure = warmupFailures.get(input.deviceIdentifier);
|
|
1063
|
+
if (failure?.generation === workerGeneration(input.deviceIdentifier) && !warmups.has(input.deviceIdentifier)) {
|
|
1064
|
+
return {
|
|
1065
|
+
backend: 'remote-xpc-hid', state: 'failed', runnerOwned: false, waitMs,
|
|
1066
|
+
errorCode: 'IOS_HID_INPUT_NOT_SENT', phase: 'worker_warmup', reason: failure.message,
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
return {
|
|
1070
|
+
backend: 'remote-xpc-hid', state: 'warming', runnerOwned: false, waitMs,
|
|
1071
|
+
readinessBudgetMs: boundedBudgetMs,
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
function numberTiming(result: Record<string, unknown>, key: string): number | undefined {
|
|
1076
|
+
const timings = result.timings;
|
|
1077
|
+
if (!timings || typeof timings !== 'object' || Array.isArray(timings)) return undefined;
|
|
1078
|
+
const value = (timings as Record<string, unknown>)[key];
|
|
1079
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
async function waitForMutationWorker(
|
|
1083
|
+
input: Pick<RemoteXpcHidInput, 'controllerHome' | 'deviceIdentifier' | 'udid'>,
|
|
1084
|
+
): Promise<{ worker: WorkerRecord; waitMs: number; reusedWorker: boolean }> {
|
|
1085
|
+
const startedAt = performance.now();
|
|
1086
|
+
const initiallyReady = workers.get(input.deviceIdentifier)?.readyState === 'ready';
|
|
1087
|
+
if (!workers.has(input.deviceIdentifier) && !warmups.has(input.deviceIdentifier)) {
|
|
1088
|
+
prewarmRemoteXpcHid(input);
|
|
1089
|
+
}
|
|
1090
|
+
const current = workers.get(input.deviceIdentifier);
|
|
1091
|
+
const warmup = warmups.get(input.deviceIdentifier);
|
|
1092
|
+
const readiness = current
|
|
1093
|
+
? current.ready.then(() => undefined).catch(() => undefined)
|
|
1094
|
+
: warmup;
|
|
1095
|
+
if (readiness) {
|
|
1096
|
+
let timer: NodeJS.Timeout | undefined;
|
|
1097
|
+
try {
|
|
1098
|
+
await Promise.race([
|
|
1099
|
+
readiness,
|
|
1100
|
+
new Promise<void>((resolve) => {
|
|
1101
|
+
timer = setTimeout(resolve, MUTATION_READY_BUDGET_MS);
|
|
1102
|
+
timer.unref?.();
|
|
1103
|
+
}),
|
|
1104
|
+
]);
|
|
1105
|
+
} finally {
|
|
1106
|
+
if (timer) clearTimeout(timer);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
const waitMs = Math.round((performance.now() - startedAt) * 100) / 100;
|
|
1110
|
+
const worker = workers.get(input.deviceIdentifier);
|
|
1111
|
+
if (!worker || worker.readyState !== 'ready') {
|
|
1112
|
+
const failure = warmupFailures.get(input.deviceIdentifier);
|
|
1113
|
+
if (failure?.generation === workerGeneration(input.deviceIdentifier) && !warmups.has(input.deviceIdentifier)) {
|
|
1114
|
+
throw warmupFailureError(input.deviceIdentifier, failure);
|
|
1115
|
+
}
|
|
1116
|
+
throw new AssistantPluginError(
|
|
1117
|
+
'IOS_HID_INPUT_NOT_READY',
|
|
1118
|
+
`RemoteXPC HID is still warming after the ${MUTATION_READY_BUDGET_MS}ms mutation readiness budget. No input was sent; retry the same action after prewarm completes.`,
|
|
1119
|
+
{
|
|
1120
|
+
retryable: true,
|
|
1121
|
+
details: {
|
|
1122
|
+
deviceIdentifier: input.deviceIdentifier,
|
|
1123
|
+
mutationDispatched: false,
|
|
1124
|
+
workerReadyWaitMs: waitMs,
|
|
1125
|
+
readinessBudgetMs: MUTATION_READY_BUDGET_MS,
|
|
1126
|
+
prewarmContinues: warmups.has(input.deviceIdentifier) || worker?.readyState === 'starting',
|
|
1127
|
+
},
|
|
1128
|
+
},
|
|
1129
|
+
);
|
|
1130
|
+
}
|
|
1131
|
+
return { worker, waitMs, reusedWorker: initiallyReady };
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
async function executeDefault(input: RemoteXpcHidInput): Promise<RemoteXpcHidResult> {
|
|
1135
|
+
const readiness = await waitForMutationWorker(input);
|
|
1136
|
+
const worker = readiness.worker;
|
|
1137
|
+
try {
|
|
1138
|
+
const request = await workerRequest(worker, input);
|
|
1139
|
+
return {
|
|
1140
|
+
backend: 'remote-xpc-hid', reusedWorker: readiness.reusedWorker, endpoint: worker.endpoint, result: request.result,
|
|
1141
|
+
timings: {
|
|
1142
|
+
workerStartupMs: readiness.reusedWorker ? 0 : readiness.waitMs,
|
|
1143
|
+
workerReadyMs: request.workerReadyMs,
|
|
1144
|
+
requestMs: request.requestMs,
|
|
1145
|
+
hidMs: numberTiming(request.result, 'hidMs'),
|
|
1146
|
+
},
|
|
1147
|
+
};
|
|
1148
|
+
} catch (error) {
|
|
1149
|
+
stopWorker(worker);
|
|
1150
|
+
const workerError = error instanceof WorkerRequestError ? error : undefined;
|
|
1151
|
+
const mutationDispatched = workerError?.mutationDispatched;
|
|
1152
|
+
const message = error instanceof Error ? error.message : 'RemoteXPC HID input failed.';
|
|
1153
|
+
throw new AssistantPluginError(
|
|
1154
|
+
mutationDispatched === false ? 'IOS_HID_INPUT_NOT_SENT' : 'IOS_HID_INPUT_FAILED',
|
|
1155
|
+
mutationDispatched === false ? `RemoteXPC input failed before any HID mutation was sent: ${message}` : message,
|
|
1156
|
+
{
|
|
1157
|
+
retryable: true,
|
|
1158
|
+
details: {
|
|
1159
|
+
deviceIdentifier: input.deviceIdentifier,
|
|
1160
|
+
...(workerError?.phase ? { phase: workerError.phase } : {}),
|
|
1161
|
+
...(mutationDispatched === undefined ? {} : { mutationDispatched }),
|
|
1162
|
+
},
|
|
1163
|
+
},
|
|
1164
|
+
);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
export async function executeRemoteXpcHidInput(input: RemoteXpcHidInput): Promise<RemoteXpcHidResult> {
|
|
1169
|
+
validateInput(input);
|
|
1170
|
+
return testExecutor ? await testExecutor(input) : await executeDefault(input);
|
|
1171
|
+
}
|