@moretea-labs/forge 1.8.1 → 1.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (328) hide show
  1. package/AGENTS.md +5 -2
  2. package/CHANGELOG.md +20 -0
  3. package/README.en.md +24 -3
  4. package/README.md +10 -6
  5. package/README.zh-CN.md +5 -3
  6. package/THIRD_PARTY_NOTICES.md +1 -2
  7. package/adapters/chatgpt/browser-delivery-runtime.ts +5 -3
  8. package/adapters/chatgpt/provider-delivery.ts +1 -1
  9. package/adapters/chatgpt/work-conversation-binding-store.ts +20 -1
  10. package/adapters/computer/chatgpt-extension-target.ts +295 -50
  11. package/adapters/computer/chrome-extension/background.js +227 -71
  12. package/adapters/computer/chrome-extension/content.js +181 -157
  13. package/adapters/computer/chrome-extension/manifest.json +3 -1
  14. package/adapters/computer/desktop-operator-provider.ts +18 -6
  15. package/adapters/computer/interaction-target-authority.ts +39 -9
  16. package/adapters/mcp/access-mode.ts +18 -39
  17. package/adapters/mcp/auth.ts +2 -6
  18. package/adapters/mcp/instructions.ts +2 -2
  19. package/adapters/mcp/multi-repository.ts +7 -28
  20. package/adapters/mcp/runtime-gateway/context-adapter.ts +79 -40
  21. package/adapters/mcp/runtime-gateway/context-pack-sidecar.ts +7 -15
  22. package/adapters/mcp/runtime-gateway/execution-tools.ts +7 -13
  23. package/adapters/mcp/runtime-gateway/plugin-adapter.ts +16 -3
  24. package/adapters/mcp/runtime-gateway/process-tools.ts +3 -21
  25. package/adapters/mcp/runtime-gateway/router.ts +3 -5
  26. package/adapters/mcp/runtime-gateway/routing-policy.ts +17 -10
  27. package/adapters/mcp/runtime-gateway/runtime-observation-adapter.ts +1 -1
  28. package/adapters/mcp/runtime-gateway/runtime-tool-definitions.ts +14 -9
  29. package/adapters/mcp/runtime-gateway/runtime-tools.ts +1 -5
  30. package/adapters/mcp/runtime-gateway/scheduler-adapter.ts +36 -1
  31. package/adapters/mcp/runtime-gateway/status-inbox-adapter.ts +58 -4
  32. package/adapters/mcp/runtime-gateway/typescript-navigation-sidecar.ts +7 -3
  33. package/adapters/mcp/runtime-gateway/work-adapter.ts +16 -16
  34. package/adapters/mcp/runtime-gateway/work-input-wire-migration.ts +0 -17
  35. package/adapters/mcp/runtime-gateway/work-semantic-operations.ts +69 -12
  36. package/adapters/mcp/runtime-gateway/workflow-supervisor-adapter.ts +26 -2
  37. package/adapters/mcp/server.ts +24 -3
  38. package/adapters/mcp/setup.ts +4 -10
  39. package/adapters/mcp/tool-mapping/access-tools.ts +0 -6
  40. package/adapters/mcp/tool-mapping/legacy-tool-service.ts +672 -2935
  41. package/adapters/mcp/tool-mapping/post-finalize-work-attribution.ts +1 -1
  42. package/adapters/mcp/tool-mapping/repository-tools.ts +52 -86
  43. package/adapters/mcp/toolset-names.ts +11 -42
  44. package/adapters/mcp/toolset.ts +16 -90
  45. package/{src/cli → adapters}/mcp/transports/AGENTS.md +5 -5
  46. package/{src/cli → adapters}/mcp/transports/CLAUDE.md +5 -5
  47. package/adapters/mcp/transports/http-observation.ts +18 -6
  48. package/adapters/mcp/transports/http-post-lifecycle.ts +19 -0
  49. package/adapters/mcp/transports/http.ts +54 -14
  50. package/adapters/mcp/types.ts +0 -6
  51. package/assets/hooks/lib/session-state.sh +0 -38
  52. package/assets/hooks/post-bash.sh +1 -1
  53. package/assets/hooks/post-tool-observer.sh +0 -5
  54. package/assets/hooks/prompt-guard.sh +0 -55
  55. package/assets/partials/04-project-structure.partial.md +1 -1
  56. package/assets/partials/07-footer.partial.md +1 -1
  57. package/assets/partials-agents/02-operating-mode.partial.md +1 -1
  58. package/assets/partials-agents/08-deep-docs.partial.md +1 -1
  59. package/assets/reference-configs/agentic-development-flow.md +1 -1
  60. package/assets/reference-configs/external-tooling.md +0 -76
  61. package/assets/reference-configs/release-deploy.md +1 -1
  62. package/assets/templates/helpers/architecture-queue.sh +1 -1
  63. package/assets/templates/helpers/check-agent-tooling.sh +1 -327
  64. package/assets/templates/helpers/check-task-workflow.sh +1 -1
  65. package/assets/templates/helpers/ensure-task-workflow.sh +0 -14
  66. package/assets/templates/helpers/migrate-project-template.sh +2 -2
  67. package/assets/workflow-contract.v1.json +0 -2
  68. package/docs/README.md +5 -2
  69. package/docs/architecture/CURRENT.md +114 -30
  70. package/docs/architecture/decisions/20260904-ios-device-migration-baseline.md +42 -0
  71. package/docs/architecture/decisions/20260918-stable-candidate-release-lifecycle.md +15 -2
  72. package/docs/architecture/decisions/20260924-thin-semantic-working-context.md +4 -2
  73. package/docs/architecture/decisions/20261001-supervisor-turn-authority.md +109 -4
  74. package/docs/architecture/decisions/20261009-exact-code-context-discovery.md +19 -0
  75. package/docs/architecture/decisions/20261009-generated-xcode-cache-retention.md +37 -0
  76. package/docs/architecture/decisions/20261011-autonomous-continuation-convergence.md +597 -0
  77. package/docs/architecture/decisions/20261017-edit-session-finalization-reconciliation.md +17 -0
  78. package/docs/architecture/decisions/20261019-retire-host-skills.md +16 -0
  79. package/docs/architecture/diagrams/agentic-dev-plugin-architecture.html +1 -1
  80. package/docs/architecture/domains/public-surface.md +3 -5
  81. package/docs/architecture/domains/verification.md +5 -5
  82. package/docs/architecture/modules/controller-runtime/transport-lifecycle.md +2 -2
  83. package/docs/architecture/requests/2026-09-04-computer-plugin-workflow-taxonomy.md +4 -4
  84. package/docs/architecture/reviews/20261015-code-search-runtime-acceptance.md +75 -0
  85. package/docs/forge-plugin-management.md +14 -2
  86. package/docs/operations/controller-browser-plugin.md +23 -0
  87. package/docs/operations/features.md +6 -7
  88. package/docs/operations/features.zh-CN.md +1 -1
  89. package/docs/operations/github-repository.md +4 -4
  90. package/docs/operations/github-semantic-release-projection.md +20 -0
  91. package/docs/operations/homebrew.md +2 -2
  92. package/docs/operations/mcp-tool-exposure.md +13 -54
  93. package/docs/operations/runtime-performance-diagnostics.md +8 -0
  94. package/docs/operations/standalone-disaster-recovery.md +1 -1
  95. package/docs/tutorials/01-install-and-start.zh-CN.md +1 -1
  96. package/docs/tutorials/03-first-repository-task.md +2 -0
  97. package/docs/wiki/Computer-and-Plugins.md +78 -0
  98. package/docs/wiki/Computer-and-Plugins.zh-CN.md +88 -0
  99. package/docs/wiki/Home.md +16 -2
  100. package/docs/wiki/Implementation.md +24 -12
  101. package/docs/wiki/Installation.md +28 -32
  102. package/docs/wiki/Integrations.md +4 -0
  103. package/docs/wiki/Quick-Start.md +35 -22
  104. package/docs/wiki/Releases-and-Upgrades.md +15 -25
  105. package/docs/wiki/_Sidebar.md +5 -1
  106. package/package.json +1 -12
  107. package/packages/kernel/controller/infrastructure/controller-session-store.ts +63 -38
  108. package/packages/kernel/identity/infrastructure/activity-projection-store.ts +10 -0
  109. package/packages/kernel/scheduler/application/eligibility.ts +4 -1
  110. package/packages/kernel/scheduler/infrastructure/schedule-store.ts +13 -10
  111. package/packages/kernel/work/application/work-service.ts +1 -0
  112. package/packages/kernel/work/domain/repository-completion-receipt.ts +1 -1
  113. package/packages/kernel/work/domain/state-machine.ts +9 -2
  114. package/packages/kernel/work/infrastructure/work-contract-store.ts +28 -1
  115. package/packages/plugin-runtime/browser/provider-registry.ts +12 -36
  116. package/packages/plugin-runtime/computer/conversation-target.ts +17 -7
  117. package/scripts/assemble-template.ts +11 -72
  118. package/scripts/check-mcp-compatibility.ts +55 -74
  119. package/scripts/check-package-identity.mjs +0 -5
  120. package/scripts/check-runtime-architecture.mjs +305 -28
  121. package/scripts/init-project.sh +0 -46
  122. package/scripts/install-ios-hid-toolchain.sh +60 -0
  123. package/scripts/install-standalone-recovery.ts +1 -1
  124. package/scripts/lib/project-init-lib.sh +3 -3
  125. package/scripts/migrate-windows-wsl-controller-home.ts +2 -0
  126. package/scripts/open-source-audit-allowlist.txt +0 -1
  127. package/scripts/run-governed-gate.ts +2 -0
  128. package/scripts/smoke-ios-physical-device.ts +38 -10
  129. package/scripts/smoke-mcp-http-runtime.ts +7 -8
  130. package/scripts/smoke-mcp-tool-surface.ts +27 -41
  131. package/scripts/smoke-workflow-supervisor-chrome-adapter.ts +6 -3
  132. package/scripts/sync-generated-authority.mjs +0 -1
  133. package/src/cli/agent-jobs/executor-health.ts +1 -1
  134. package/src/cli/agent-jobs/integration.ts +2 -2
  135. package/src/cli/chatgpt-browser/file-policy.ts +2 -2
  136. package/src/cli/commands/doctor.ts +1 -143
  137. package/src/cli/commands/global-runtime.ts +3 -196
  138. package/src/cli/commands/init-hook.ts +6 -23
  139. package/src/cli/commands/init.ts +3 -252
  140. package/src/cli/commands/mcp.ts +0 -5
  141. package/src/cli/commands/prompt-guard-decision.ts +0 -2
  142. package/src/cli/commands/recovery.ts +2 -14
  143. package/src/cli/commands/repository.ts +17 -1
  144. package/src/cli/commands/setup-execution.ts +41 -0
  145. package/src/cli/controller/completion-orchestrator.ts +1 -1
  146. package/src/cli/controller/composite-operations.ts +1 -1
  147. package/src/cli/controller/context/instruction-context.ts +1 -1
  148. package/src/cli/controller/context/known-paths.ts +2 -2
  149. package/src/cli/controller/context/query-planning.ts +11 -4
  150. package/src/cli/controller/context/rule-resolution.ts +2 -2
  151. package/src/cli/controller/context/source-materializer.ts +2 -2
  152. package/src/cli/controller/context/structural-provider.ts +84 -0
  153. package/src/cli/controller/context/structural.ts +6 -6
  154. package/src/cli/controller/context/types.ts +8 -9
  155. package/src/cli/controller/context-pack.ts +97 -76
  156. package/src/cli/controller/operational-plan.ts +1 -1
  157. package/src/cli/controller/systemd-user.ts +4 -10
  158. package/src/cli/editing/edit-session.ts +17 -5
  159. package/src/cli/github/github.ts +4 -4
  160. package/src/cli/github/semantic-projection.ts +174 -0
  161. package/src/cli/hook/prompt-intents.ts +0 -9
  162. package/src/cli/index.ts +1 -43
  163. package/src/cli/local-bridge/connector-freshness.ts +1 -12
  164. package/src/cli/local-bridge/console-automations.ts +1 -1
  165. package/src/cli/local-bridge/console-view-models.ts +1 -1
  166. package/src/cli/local-bridge/facade-api.ts +0 -2
  167. package/src/cli/repositories/command-classifier.ts +0 -9
  168. package/src/cli/repositories/command-scope.ts +2 -2
  169. package/src/cli/repositories/locks.ts +20 -0
  170. package/src/cli/repositories/registry.ts +2 -1
  171. package/src/cli/repositories/safe-patch.ts +2 -12
  172. package/src/cli/repositories/structured-git.ts +39 -3
  173. package/src/cli/repository/inspector.ts +185 -343
  174. package/src/effects/process-runner.ts +17 -1
  175. package/src/runtime/context/generic-lsp-provider.ts +1 -1
  176. package/src/runtime/context/semantic-navigation-contract.ts +7 -0
  177. package/src/runtime/context/semantic-navigation.ts +1 -1
  178. package/src/runtime/control-plane/cleanup-artifact-retention.ts +4 -4
  179. package/src/runtime/control-plane/execution/work-delivery-service.ts +150 -31
  180. package/src/runtime/control-plane/execution/work-handle-authority.ts +12 -4
  181. package/src/runtime/control-plane/execution/work-handle-store.ts +53 -1
  182. package/src/runtime/control-plane/execution/work-operation-service.ts +20 -10
  183. package/src/runtime/control-plane/execution/work-preparation-service.ts +108 -4
  184. package/src/runtime/control-plane/execution/work-process-evidence.ts +2 -2
  185. package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +28 -13
  186. package/src/runtime/control-plane/execution/work-validation-reconciler.ts +5 -1
  187. package/src/runtime/control-plane/execution/work-verification-snapshot.ts +1 -1
  188. package/src/runtime/control-plane/facade/capability-registry.ts +2 -0
  189. package/src/runtime/control-plane/facade/work-continuation.ts +1 -1
  190. package/src/runtime/control-plane/generated-cache-retention.ts +114 -63
  191. package/src/runtime/control-plane/global-scheduler/maintenance.ts +20 -8
  192. package/src/runtime/control-plane/global-scheduler/reconciliation.ts +1 -11
  193. package/src/runtime/control-plane/governance/external-effects.ts +1 -1
  194. package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +1 -0
  195. package/src/runtime/control-plane/runtime-cleanup.ts +12 -93
  196. package/src/runtime/evidence/sensitive-output.ts +22 -2
  197. package/src/runtime/execution/jobs/child-reference.ts +11 -0
  198. package/src/runtime/execution/jobs/receipt-recovery.ts +1 -11
  199. package/src/runtime/execution/process-runtime/command-facade.ts +3 -3
  200. package/src/runtime/execution/process-runtime/runtime.ts +20 -16
  201. package/src/runtime/execution/thin-harness/execution-router.ts +16 -16
  202. package/src/runtime/execution/thin-harness/fast-executor.ts +2 -2
  203. package/src/runtime/execution/thin-harness/search-worker.ts +2 -2
  204. package/src/runtime/execution/workers/executor.ts +1 -2
  205. package/src/runtime/maintenance/cleanup.ts +41 -5
  206. package/src/runtime/plugins/action-application.ts +12 -1
  207. package/src/runtime/plugins/app-store-connect-adapter.ts +252 -4
  208. package/src/runtime/plugins/browser-adapter.ts +381 -313
  209. package/src/runtime/plugins/browser-macos-bridge.ts +114 -112
  210. package/src/runtime/plugins/browser-manifest-surface.ts +2 -1
  211. package/src/runtime/plugins/browser-registration.ts +2 -0
  212. package/src/runtime/plugins/browser-runtime.ts +26 -35
  213. package/src/runtime/plugins/computer-chatgpt-browser-adapter-installer.ts +11 -2
  214. package/src/runtime/plugins/computer-registration.ts +76 -9
  215. package/src/runtime/plugins/github-adapter.ts +30 -3
  216. package/src/runtime/plugins/gmail-adapter.ts +166 -1
  217. package/src/runtime/plugins/google-shared.ts +10 -5
  218. package/src/runtime/plugins/ios/remote-xpc-hid.ts +70 -30
  219. package/src/runtime/plugins/ios-adapter.ts +45 -40
  220. package/src/runtime/plugins/ios-agent-device.ts +54 -4
  221. package/src/runtime/plugins/ios-device-ownership.ts +103 -0
  222. package/src/runtime/plugins/ios-physical-device.ts +497 -79
  223. package/src/runtime/plugins/local-system-adapter.ts +1 -99
  224. package/src/runtime/plugins/store.ts +80 -7
  225. package/src/runtime/plugins/types.ts +4 -0
  226. package/src/runtime/projections/git-status-sampler.ts +2 -19
  227. package/src/runtime/recovery/actions.ts +1 -1
  228. package/src/runtime/recovery/lifecycle-inventory.ts +1 -11
  229. package/src/runtime/recovery/probe.ts +2 -2
  230. package/src/runtime/release/release-coordinator.ts +20 -4
  231. package/src/runtime/root/controller-progression-composition.ts +67 -5
  232. package/src/runtime/root/package-connector-service.ts +123 -11
  233. package/src/runtime/root/package-runtime-release.ts +6 -18
  234. package/src/runtime/root/package-runtime-service.ts +7 -13
  235. package/src/runtime/root/release-execution-canary.ts +18 -5
  236. package/src/runtime/root/release-manifest.ts +0 -34
  237. package/src/runtime/root/release-materialize.ts +0 -78
  238. package/src/runtime/root/runtime.ts +1 -2
  239. package/src/runtime/root/scheduled-controller-composition.ts +2 -1
  240. package/src/runtime/root/service.ts +90 -11
  241. package/src/runtime/root/types.ts +0 -6
  242. package/src/runtime/root/workflow-supervisor-composition.ts +207 -19
  243. package/src/runtime/root/workflow-supervisor-runtime.ts +365 -10
  244. package/src/runtime/safe-tooling/google-credential-store.ts +1 -11
  245. package/src/runtime/safe-tooling/ios-development.ts +22 -8
  246. package/src/runtime/safe-tooling/job-summary.ts +1 -1
  247. package/src/runtime/safe-tooling/workspace-auth.ts +4 -1
  248. package/src/runtime/shared/atomic-text-file.ts +21 -0
  249. package/src/runtime/shared/json-files.ts +8 -3
  250. package/src/runtime/standalone-recovery/core.ts +189 -174
  251. package/src/runtime/standalone-recovery/entry.ts +62 -30
  252. package/src/runtime/standalone-recovery/operation-lock.ts +11 -1
  253. package/src/runtime/workflow/schedules/chatgpt-automation-migration.ts +1 -0
  254. package/src/runtime/workflow/schedules/engine.ts +36 -14
  255. package/src/runtime/workflow/schedules/pending-activation.ts +11 -4
  256. package/src/runtime/workflow/schedules/work-continuation.ts +51 -6
  257. package/supervisor/client.ts +31 -14
  258. package/supervisor/control-plane.ts +210 -115
  259. package/supervisor/forge-validators.ts +88 -21
  260. package/supervisor/native-browser-adapter.ts +393 -28
  261. package/supervisor/native-messaging/host.ts +21 -2
  262. package/supervisor/protocol.ts +74 -26
  263. package/supervisor/server.ts +16 -8
  264. package/supervisor/store.ts +476 -57
  265. package/supervisor/types.ts +19 -13
  266. package/SKILL.md +0 -427
  267. package/adapters/computer/chatgpt-target-router.ts +0 -103
  268. package/adapters/mcp/runtime-gateway/automation-receipt-adapter.ts +0 -87
  269. package/adapters/mcp/runtime-gateway/work-validation-reconciler.ts +0 -1
  270. package/adapters/mcp/toolset-selection.ts +0 -41
  271. package/agents/openai.yaml +0 -4
  272. package/assets/skill-hooks.json +0 -41
  273. package/assets/skill-version.json +0 -189
  274. package/assets/skills/claude-review/SKILL.md +0 -231
  275. package/assets/skills/codex-review/SKILL.md +0 -103
  276. package/assets/templates/helpers/check-skill-version.ts +0 -193
  277. package/scripts/check-skill-version.ts +0 -5
  278. package/scripts/ensure-codegraph.sh +0 -16
  279. package/scripts/run-skill-hook.ts +0 -244
  280. package/scripts/sync-codex-installed-copies.sh +0 -182
  281. package/src/cli/commands/tools.ts +0 -167
  282. package/src/cli/mcp/access-mode.ts +0 -2
  283. package/src/cli/mcp/access-tools.ts +0 -2
  284. package/src/cli/mcp/audit.ts +0 -2
  285. package/src/cli/mcp/auth.ts +0 -2
  286. package/src/cli/mcp/instructions.ts +0 -2
  287. package/src/cli/mcp/legacy-context.ts +0 -2
  288. package/src/cli/mcp/legacy-tool-service.ts +0 -2
  289. package/src/cli/mcp/multi-repository.ts +0 -2
  290. package/src/cli/mcp/oauth.ts +0 -2
  291. package/src/cli/mcp/openai-secure-tunnel.ts +0 -2
  292. package/src/cli/mcp/paths.ts +0 -2
  293. package/src/cli/mcp/policy.ts +0 -2
  294. package/src/cli/mcp/post-finalize-work-attribution.ts +0 -2
  295. package/src/cli/mcp/proxy-env.ts +0 -2
  296. package/src/cli/mcp/readiness-projection.ts +0 -2
  297. package/src/cli/mcp/redaction.ts +0 -2
  298. package/src/cli/mcp/repo.ts +0 -2
  299. package/src/cli/mcp/repository-tools.ts +0 -2
  300. package/src/cli/mcp/server.ts +0 -2
  301. package/src/cli/mcp/setup.ts +0 -2
  302. package/src/cli/mcp/tool-capability-cache.ts +0 -2
  303. package/src/cli/mcp/tool-contract.ts +0 -2
  304. package/src/cli/mcp/tools.ts +0 -2
  305. package/src/cli/mcp/toolset-names.ts +0 -2
  306. package/src/cli/mcp/toolset-selection.ts +0 -2
  307. package/src/cli/mcp/toolset.ts +0 -2
  308. package/src/cli/mcp/transports/http.ts +0 -2
  309. package/src/cli/mcp/transports/session-registry.ts +0 -2
  310. package/src/cli/mcp/transports/stdio.ts +0 -2
  311. package/src/cli/mcp/types.ts +0 -2
  312. package/src/cli/mcp/umbrella-tools.ts +0 -2
  313. package/src/cli/tools/codegraph.ts +0 -695
  314. package/src/core/adoption/helper-wrapper-plan.ts +0 -159
  315. package/src/runtime/context/codegraph-cache-boundary.ts +0 -255
  316. package/src/runtime/context/codegraph-read-provider.ts +0 -611
  317. package/src/runtime/context/codegraph-sidecar.cjs +0 -248
  318. package/src/runtime/control-plane/codegraph-cache-retention.ts +0 -164
  319. package/src/runtime/gateway/mcp/execution-tools.ts +0 -2
  320. package/src/runtime/gateway/mcp/legacy-ios-tool-adapter.ts +0 -2
  321. package/src/runtime/gateway/mcp/persisted-check-process.ts +0 -2
  322. package/src/runtime/gateway/mcp/process-tools.ts +0 -2
  323. package/src/runtime/gateway/mcp/router.ts +0 -2
  324. package/src/runtime/gateway/mcp/runtime-tool-definitions.ts +0 -2
  325. package/src/runtime/gateway/mcp/runtime-tools.ts +0 -2
  326. package/src/runtime/gateway/mcp/work-validation-reconciler.ts +0 -2
  327. package/src/runtime/plugins/browser-provider-registry.ts +0 -2
  328. package/src/runtime/plugins/computer-chatgpt-macos-target.ts +0 -891
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from 'crypto';
2
- import { existsSync, mkdirSync, readFileSync } from 'fs';
2
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, unlinkSync } from 'fs';
3
3
  import { join } from 'path';
4
4
  import { spawnSync } from 'child_process';
5
5
  import { performance } from 'perf_hooks';
@@ -7,6 +7,7 @@ import { repositoryControllerRoot } from '../../cli/repositories/controller-home
7
7
  import { runBoundedProcess } from '../execution/thin-harness/async-process';
8
8
  import { readJsonFile, writeJsonAtomic } from '../shared/json-files';
9
9
  import { AssistantPluginError, toAssistantPluginError } from './errors';
10
+ import { ensureIosDeviceOwnership, iosCoreDeviceOwnershipId, releaseIosDeviceOwnership } from './ios-device-ownership';
10
11
  import {
11
12
  interactionAutomationEngine,
12
13
  interactionMayOwnTarget,
@@ -25,7 +26,7 @@ import type {
25
26
  AssistantPluginAuthorizationTarget,
26
27
  AssistantPluginCapability,
27
28
  } from './types';
28
- import { executeRemoteXpcHidInput, prewarmRemoteXpcHid, remoteXpcHidStatus, stopRemoteXpcHidForDevice } from './ios/remote-xpc-hid';
29
+ import { executeRemoteXpcHidInput, remoteXpcHidStatus, stopRemoteXpcHidForDevice } from './ios/remote-xpc-hid';
29
30
 
30
31
  const PROVIDER = 'ios-device' as const;
31
32
  const SESSION_EXPIRY_MS = 2 * 60 * 60_000;
@@ -37,6 +38,9 @@ const MAX_JSON_BYTES = 64 * 1024;
37
38
  const MAX_EVENTS = 200;
38
39
  const MAX_BATCH_STEPS = 20;
39
40
  const MAX_BATCH_WAIT_MS = 5_000;
41
+ const MAX_EXPORTED_DOCUMENT_BYTES = 20 * 1024 * 1024;
42
+ const MAX_DOCUMENT_INDEX_BYTES = 512 * 1024;
43
+ const MAX_SCREENSHOT_BYTES = 25 * 1024 * 1024;
40
44
 
41
45
  interface CommandResult {
42
46
  ok: boolean;
@@ -259,7 +263,11 @@ function statePath(input: AssistantPluginActionExecutionInput, interactionId: st
259
263
 
260
264
  function artifactDir(input: AssistantPluginActionExecutionInput, interactionId: string): string {
261
265
  const path = join(controllerRoot(input), 'artifacts', 'ios', 'physical-device', sanitize(interactionId));
262
- mkdirSync(path, { recursive: true });
266
+ mkdirSync(path, { recursive: true, mode: 0o700 });
267
+ if (!lstatSync(path).isDirectory()) {
268
+ throw new AssistantPluginError('IOS_DEVICE_ARTIFACT_DIR_INVALID', 'The private iPhone artifact directory is not a regular directory.', { retryable: false });
269
+ }
270
+ chmodSync(path, 0o700);
263
271
  return path;
264
272
  }
265
273
 
@@ -322,6 +330,19 @@ function requireFiniteNumber(value: unknown, name: string): number {
322
330
  throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', `${name} must be a finite number.`, { retryable: false });
323
331
  }
324
332
 
333
+ function requireAppDocumentPath(value: unknown): string {
334
+ const path = requireString(value, 'path');
335
+ const parts = path.split('/');
336
+ if (parts.length !== 2 || parts[0] !== 'Documents'
337
+ || !/^[^\\/\u0000-\u001f\u007f]+\.(?:pdf|png|jpe?g|heic)$/i.test(parts[1] ?? '')
338
+ || parts[1] === '.' || parts[1] === '..' || path.length > 512) {
339
+ throw new AssistantPluginError('IOS_DEVICE_DOCUMENT_PATH_INVALID',
340
+ 'Provide one exact PDF/image filename immediately within the selected app Documents directory.',
341
+ { retryable: false });
342
+ }
343
+ return path;
344
+ }
345
+
325
346
  function requireBundleId(value: unknown): string {
326
347
  const bundleId = requireString(value, 'bundle_id');
327
348
  if (!/^[A-Za-z0-9][A-Za-z0-9.-]+$/.test(bundleId) || !bundleId.includes('.') || /^[a-z][a-z0-9+.-]*:\/\//i.test(bundleId)) {
@@ -478,7 +499,12 @@ async function physicalDeviceLockState(input: AssistantPluginActionExecutionInpu
478
499
 
479
500
  async function requirePhysicalDeviceUnlocked(input: AssistantPluginActionExecutionInput, device: PhysicalDevice): Promise<void> {
480
501
  const lockState = await physicalDeviceLockState(input, device);
481
- if (lockState.passcodeRequired !== true) return;
502
+ if (lockState.passcodeRequired === false) return;
503
+ if (lockState.passcodeRequired !== true) {
504
+ throw new AssistantPluginError('IOS_DEVICE_LOCK_STATE_UNVERIFIED',
505
+ 'CoreDevice did not report a verified iPhone lock state; app activation was not attempted.',
506
+ { retryable: false });
507
+ }
482
508
  throw new AssistantPluginError(
483
509
  'IOS_DEVICE_LOCKED',
484
510
  `The selected iPhone ${device.name} is locked. Unlock it before launching an app through CoreDevice.`,
@@ -520,18 +546,49 @@ async function physicalDisplayGeometry(input: AssistantPluginActionExecutionInpu
520
546
  };
521
547
  }
522
548
 
523
- function pngDisplayGeometry(path: string, pointScale?: number): DisplayGeometry | undefined {
524
- try {
525
- const header = readFileSync(path).subarray(0, 24);
526
- const signature = header.subarray(0, 8).toString('hex');
527
- if (header.length < 24 || signature !== '89504e470d0a1a0a') return undefined;
528
- const width = header.readUInt32BE(16);
529
- const height = header.readUInt32BE(20);
530
- if (width <= 1 || height <= 1) return undefined;
531
- return { width, height, pointScale };
532
- } catch {
533
- return undefined;
549
+ const PNG_CRC_TABLE = Uint32Array.from({ length: 256 }, (_, byte) => {
550
+ let crc = byte;
551
+ for (let bit = 0; bit < 8; bit += 1) {
552
+ crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);
553
+ }
554
+ return crc >>> 0;
555
+ });
556
+
557
+ function pngDisplayGeometry(image: Buffer, pointScale?: number): DisplayGeometry | undefined {
558
+ // No screenshot observation is issued until all PNG chunks have valid bounds
559
+ // and CRCs; a plausible header/trailer alone is not a usable image.
560
+ if (image.length < 57 || image.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a') return undefined;
561
+ let offset = 8;
562
+ let width = 0;
563
+ let height = 0;
564
+ let sawImageData = false;
565
+ while (offset + 12 <= image.length) {
566
+ const length = image.readUInt32BE(offset);
567
+ if (length > image.length - offset - 12) return undefined;
568
+ const kind = image.toString('ascii', offset + 4, offset + 8);
569
+ const end = offset + 8 + length;
570
+ if (!/^[A-Za-z]{4}$/.test(kind)) return undefined;
571
+ let crc = 0xffffffff;
572
+ for (let pos = offset + 4; pos < end; pos += 1) {
573
+ crc = PNG_CRC_TABLE[(crc ^ image[pos]!) & 0xff]! ^ (crc >>> 8);
574
+ }
575
+ if (((crc ^ 0xffffffff) >>> 0) !== image.readUInt32BE(end)) return undefined;
576
+ if (kind === 'IHDR') {
577
+ if (offset !== 8 || length !== 13) return undefined;
578
+ width = image.readUInt32BE(offset + 8);
579
+ height = image.readUInt32BE(offset + 12);
580
+ if (width <= 1 || height <= 1 || width > 10_000 || height > 10_000
581
+ || image[offset + 18] !== 0 || image[offset + 19] !== 0 || (image[offset + 20] ?? 2) > 1) return undefined;
582
+ } else if (kind === 'IDAT') {
583
+ if (!width || length === 0) return undefined;
584
+ sawImageData = true;
585
+ } else if (kind === 'IEND') {
586
+ return sawImageData && length === 0 && end + 4 === image.length
587
+ ? { width, height, pointScale } : undefined;
588
+ }
589
+ offset = end + 4;
534
590
  }
591
+ return undefined;
535
592
  }
536
593
 
537
594
  async function sessionDisplayGeometry(
@@ -632,12 +689,42 @@ function expirePhysicalDeviceSessions(input: AssistantPluginActionExecutionInput
632
689
  error: { code: 'IOS_DEVICE_SESSION_EXPIRED', message: 'The physical iOS interaction expired.' },
633
690
  });
634
691
  appendEvent(input, record.interactionId, 'session_expired');
635
- if (state?.device.identifier) stopRemoteXpcHidForDevice(state.device.identifier);
692
+ if (state?.device.identifier) {
693
+ stopRemoteXpcHidForDevice(state.device.identifier);
694
+ releaseIosDeviceOwnership(input.controllerHome, record.owner.repoId, record.interactionId, iosCoreDeviceOwnershipId(state.device));
695
+ }
636
696
  expired += 1;
637
697
  }
638
698
  return expired;
639
699
  }
640
700
 
701
+ function deviceOwnershipError(
702
+ selected: PhysicalDevice,
703
+ result: ReturnType<typeof ensureIosDeviceOwnership>,
704
+ ): AssistantPluginError {
705
+ return new AssistantPluginError('PLUGIN_RESOURCE_BUSY', 'The selected iPhone is already owned by another active Forge interaction.', {
706
+ retryable: true,
707
+ details: {
708
+ device: { identifier: selected.identifier, name: selected.name },
709
+ blocker: result.blocker,
710
+ },
711
+ });
712
+ }
713
+
714
+ function reusablePhysicalSession(
715
+ input: AssistantPluginActionExecutionInput,
716
+ selected: PhysicalDevice,
717
+ bundleId: string,
718
+ ): InteractionSessionRecord | undefined {
719
+ const aliases = [selected.identifier, selected.udid].filter((value): value is string => Boolean(value));
720
+ return listInteractionSessions(input.repoRoot, PROVIDER).find((entry) => {
721
+ if (!isInteractionSessionActive(entry.status) || interactionAutomationEngine(entry) !== 'coredevice') return false;
722
+ if (!interactionMayOwnTarget(entry, aliases)) return false;
723
+ const state = readState(input, entry.interactionId);
724
+ return state?.device.identifier === selected.identifier && state.bundleId === bundleId;
725
+ });
726
+ }
727
+
641
728
  function requireRecord(input: AssistantPluginActionExecutionInput, allowTerminal = false): { record: InteractionSessionRecord; state: PhysicalSessionState } {
642
729
  const interactionId = requireString(input.args.interaction_id, 'interaction_id');
643
730
  const record = readInteractionSession(input.repoRoot, PROVIDER, interactionId);
@@ -668,8 +755,17 @@ function requireRecord(input: AssistantPluginActionExecutionInput, allowTerminal
668
755
  });
669
756
  appendEvent(input, interactionId, 'session_expired');
670
757
  stopRemoteXpcHidForDevice(state.device.identifier);
758
+ releaseIosDeviceOwnership(input.controllerHome, record.owner.repoId, interactionId, iosCoreDeviceOwnershipId(state.device));
671
759
  throw new AssistantPluginError('IOS_DEVICE_SESSION_EXPIRED', 'The physical iOS interaction expired; open a new session.', { retryable: false });
672
760
  }
761
+ if (isInteractionSessionActive(record.status)) {
762
+ const ownership = ensureIosDeviceOwnership(input.controllerHome, input.repoId, interactionId, iosCoreDeviceOwnershipId(state.device));
763
+ if (!ownership.acquired) throw deviceOwnershipError(state.device, ownership);
764
+ const active = patchInteractionSession(input.repoRoot, PROVIDER, interactionId, {
765
+ expiresAt: new Date(hooks.now().getTime() + SESSION_EXPIRY_MS).toISOString(),
766
+ });
767
+ if (active) return { record: active, state };
768
+ }
673
769
  return { record, state };
674
770
  }
675
771
 
@@ -795,23 +891,20 @@ function requireObservedPhysicalTargetForeground(state: PhysicalSessionState): P
795
891
  function resolvePhysicalBatchForegroundObservation(
796
892
  state: PhysicalSessionState,
797
893
  observationId: string | undefined,
798
- ): { foregroundObservation: PhysicalForegroundObservation; source: 'atomic_screenshot' | 'explicit_confirmation'; screenshotAgeMs?: number } {
799
- if (observationId) {
800
- const { observation, ageMs } = recentScreenshotObservation(state, observationId);
801
- return {
802
- foregroundObservation: {
803
- observationId: observation.observationId,
804
- screenshotObservedAt: observation.observedAt,
805
- confirmedAt: timestamp(),
806
- bundleId: state.bundleId,
807
- },
808
- source: 'atomic_screenshot',
809
- screenshotAgeMs: ageMs,
810
- };
894
+ ): { foregroundObservation: PhysicalForegroundObservation; source: 'explicit_confirmation'; screenshotAgeMs?: number } {
895
+ // Observation freshness proves only that the screen was captured. It never proves
896
+ // the target app was foreground. Require a separate, model-reviewed confirmation.
897
+ const observed = observationId ? recentScreenshotObservation(state, observationId) : undefined;
898
+ const confirmed = requireObservedPhysicalTargetForeground(state);
899
+ if (observationId && confirmed.observationId !== observationId) {
900
+ throw new AssistantPluginError('IOS_DEVICE_FOREGROUND_OBSERVATION_REQUIRED',
901
+ 'The batch observation must match the separately confirmed target-app foreground.',
902
+ { retryable: true, details: { hidMutationDispatched: false } });
811
903
  }
812
904
  return {
813
- foregroundObservation: requireObservedPhysicalTargetForeground(state),
905
+ foregroundObservation: confirmed,
814
906
  source: 'explicit_confirmation',
907
+ ...(observed ? { screenshotAgeMs: observed.ageMs } : {}),
815
908
  };
816
909
  }
817
910
 
@@ -940,6 +1033,96 @@ async function waitPhysicalInputBatch(durationMs: number, signal?: AbortSignal):
940
1033
  });
941
1034
  }
942
1035
 
1036
+ /** CoreDevice screenshot is the one observation authority for the device. */
1037
+ async function capturePhysicalScreen(
1038
+ input: AssistantPluginActionExecutionInput,
1039
+ state: PhysicalSessionState,
1040
+ label: string,
1041
+ ): Promise<Record<string, unknown>> {
1042
+ const path = join(artifactDir(input, state.interactionId), `${sanitize(label)}-${hooks.now().getTime()}-${randomUUID()}.png`);
1043
+ // A new capture supersedes the old screen. A failed observation cannot leave
1044
+ // an earlier foreground confirmation available to authorize subsequent HID.
1045
+ state.lastScreenshot = undefined;
1046
+ state.foregroundObservation = undefined;
1047
+ writeState(input, state);
1048
+ try {
1049
+ const screenshotResult = await runCoreJson(input, [
1050
+ 'device', 'capture', 'screenshot', '--device', state.device.identifier, '--destination', path,
1051
+ ], 'IOS_DEVICE_SCREENSHOT_FAILED', 60_000);
1052
+ if (!existsSync(path)) {
1053
+ throw new AssistantPluginError('IOS_DEVICE_SCREENSHOT_MISSING', 'CoreDevice succeeded without creating the requested screenshot.', { retryable: false });
1054
+ }
1055
+ const file = lstatSync(path);
1056
+ // The enclosing directory is 0700 during CoreDevice's write; the finished
1057
+ // screenshot itself must also be private before it is read or returned.
1058
+ if (file.isFile()) chmodSync(path, 0o600);
1059
+ const image = file.isFile() && file.size <= MAX_SCREENSHOT_BYTES && file.size > 0 ? readFileSync(path) : undefined;
1060
+ const screenshotDisplay = image ? pngDisplayGeometry(image, state.display?.pointScale) : undefined;
1061
+ if (!screenshotDisplay || !image) {
1062
+ throw new AssistantPluginError('IOS_DEVICE_SCREENSHOT_INVALID', 'CoreDevice did not provide a bounded PNG screenshot with valid image dimensions and terminal marker.', { retryable: false });
1063
+ }
1064
+ const screenshotSha256 = createHash('sha256').update(image).digest('hex');
1065
+ const observedAt = timestamp();
1066
+ const observationId = randomUUID();
1067
+ state.display = { ...screenshotDisplay, observedAt };
1068
+ state.lastScreenshot = { observationId, observedAt, label };
1069
+ state.foregroundObservation = undefined;
1070
+ state.events.push({ at: observedAt, type: 'screenshot', details: { label, observationId } });
1071
+ state.events = state.events.slice(-MAX_EVENTS);
1072
+ writeState(input, state);
1073
+ return {
1074
+ screenshotResult: bounded(screenshotResult),
1075
+ observation: { observationId, observedAt, label, ttlMs: INPUT_FOREGROUND_OBSERVATION_TTL_MS,
1076
+ screenshotSha256, screenshotBytes: image.length, width: screenshotDisplay.width, height: screenshotDisplay.height },
1077
+ foregroundConfirmation: {
1078
+ actionId: 'physical_device_confirm_foreground',
1079
+ arguments: { interaction_id: state.interactionId, observation_id: observationId, bundle_id: state.bundleId },
1080
+ requires: ['independent visual confirmation of target app'],
1081
+ ttlMs: INPUT_FOREGROUND_OBSERVATION_TTL_MS,
1082
+ },
1083
+ artifactCandidates: [{ kind: 'ios_physical_device_screenshot', mediaType: 'image/png', path,
1084
+ sha256: screenshotSha256, bytes: image.length }],
1085
+ };
1086
+ } catch (error) {
1087
+ // A partially written or invalid image is not an observation artifact.
1088
+ if (existsSync(path)) {
1089
+ try { unlinkSync(path); } catch { /* Preserve the original capture failure. */ }
1090
+ }
1091
+ throw error;
1092
+ }
1093
+ }
1094
+
1095
+ /** After a mutation, screenshot failures are observations, never a reason to retry input. */
1096
+ async function optionalPostActionCapture(
1097
+ input: AssistantPluginActionExecutionInput,
1098
+ state: PhysicalSessionState,
1099
+ label: string,
1100
+ timingStages: TimingStages,
1101
+ ): Promise<Record<string, unknown>> {
1102
+ const startedAt = performance.now();
1103
+ try {
1104
+ const capture = await capturePhysicalScreen(input, state, label);
1105
+ recordTiming(timingStages, 'postActionScreenshot', startedAt, false);
1106
+ const { screenshotResult: _ignored, ...observation } = capture;
1107
+ return { ...observation, postActionCapture: { status: 'captured' } };
1108
+ } catch (error) {
1109
+ recordTiming(timingStages, 'postActionScreenshot', startedAt, false);
1110
+ const normalized = toAssistantPluginError(error, {
1111
+ code: 'IOS_DEVICE_SCREENSHOT_FAILED',
1112
+ message: 'The physical iPhone input completed, but its follow-up screenshot could not be captured.',
1113
+ retryable: false,
1114
+ });
1115
+ // A secondary diagnostic write must not turn a completed HID effect into a replayable error.
1116
+ try { appendEvent(input, state.interactionId, 'post_action_screenshot_failed', { code: normalized.code }); } catch { /* observation failure remains explicit */ }
1117
+ return {
1118
+ postActionCapture: {
1119
+ status: 'failed', code: normalized.code,
1120
+ mutationAlreadyApplied: true, retryOnly: 'physical_device_screenshot',
1121
+ },
1122
+ };
1123
+ }
1124
+ }
1125
+
943
1126
  export function isIosPhysicalDeviceAction(actionId: string): boolean {
944
1127
  return actionId.startsWith('physical_device_');
945
1128
  }
@@ -1044,10 +1227,10 @@ export function iosPhysicalDeviceActions(): AssistantPluginActionDescriptor[] {
1044
1227
  },
1045
1228
  {
1046
1229
  actionId: 'physical_device_open', title: 'Open physical iOS app session',
1047
- description: 'Request activation of one installed third-party app through CoreDevice and create a bounded interaction session. CoreDevice launch metadata is diagnostic only: capture a screenshot and explicitly confirm the observed foreground before any HID input. Set prewarm_input=true when runnerless input will follow; Forge then waits up to 4 seconds for a non-mutating RemoteXPC readiness result so the first HID action does not need a blind retry.',
1230
+ description: 'Open or reactivate the target app in the same exact CoreDevice iPhone session; activation metadata never proves foreground. Set observe_after_open=true to return a new screenshot without authorizing input. prewarm_input=true defers isolated RemoteXPC worker startup to the first input action instead of wasting a cross-action prewarm.',
1048
1231
  readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 2 * 60_000, cancellable: true, idempotent: false,
1049
1232
  scopes: ['ios.device'], resourceClaims: mutationClaims,
1050
- argumentsSchema: { type: 'object', properties: { device: { type: 'string' }, bundle_id: { type: 'string' }, relaunch: { type: 'boolean' }, prewarm_input: { type: 'boolean' } }, required: ['device', 'bundle_id'], additionalProperties: false },
1233
+ argumentsSchema: { type: 'object', properties: { device: { type: 'string' }, bundle_id: { type: 'string' }, relaunch: { type: 'boolean' }, prewarm_input: { type: 'boolean' }, observe_after_open: { type: 'boolean' } }, required: ['device', 'bundle_id'], additionalProperties: false },
1051
1234
  },
1052
1235
  {
1053
1236
  actionId: 'physical_device_screenshot', title: 'Capture physical iOS screenshot',
@@ -1069,18 +1252,29 @@ export function iosPhysicalDeviceActions(): AssistantPluginActionDescriptor[] {
1069
1252
  },
1070
1253
  {
1071
1254
  actionId: 'physical_device_observed_tap', title: 'Tap a freshly observed physical iPhone screen',
1072
- description: 'One-shot screen-relative tap bound to the latest fresh screenshot observation. It does not reactivate any app and consumes the observation before dispatch, so it can safely navigate system UI such as Spotlight into the target app when CoreDevice activation did not actually foreground it.',
1255
+ description: 'One-shot screenshot-bound tap for an independently inspected system-screen navigation, without reactivation. Observation freshness is not app identity: never dispatch from an unseen screenshot or use this system-navigation path for ordinary in-app taps; verify exact target foreground first and use physical_device_confirm_foreground plus physical_device_batch/tap. capture_after=true returns the next screenshot; never retry an uncertain tap.',
1073
1256
  readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 30_000, cancellable: true, idempotent: false,
1074
1257
  scopes: ['ios.device'], resourceClaims: mutationClaims,
1075
1258
  argumentsSchema: {
1076
1259
  type: 'object',
1077
- properties: { ...interactionProperty, observation_id: { type: 'string' }, x: { type: 'number', minimum: 0 }, y: { type: 'number', minimum: 0 } },
1260
+ properties: { ...interactionProperty, observation_id: { type: 'string' }, capture_after: { type: 'boolean' }, x: { type: 'number', minimum: 0 }, y: { type: 'number', minimum: 0 } },
1078
1261
  required: ['interaction_id', 'observation_id', 'x', 'y'], additionalProperties: false,
1079
1262
  },
1080
1263
  },
1264
+ {
1265
+ actionId: 'physical_device_observed_swipe', title: 'Swipe a freshly observed physical iPhone screen',
1266
+ description: 'One-shot screenshot-bound swipe for system navigation, consuming the observed screen without app reactivation. capture_after=true returns the next CoreDevice screenshot and new observation in the same call; app form gestures still require foreground confirmation.',
1267
+ readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 30_000, cancellable: true, idempotent: false,
1268
+ scopes: ['ios.device'], resourceClaims: mutationClaims,
1269
+ argumentsSchema: {
1270
+ type: 'object',
1271
+ properties: { ...interactionProperty, observation_id: { type: 'string' }, capture_after: { type: 'boolean' }, x: { type: 'number', minimum: 0 }, y: { type: 'number', minimum: 0 }, to_x: { type: 'number', minimum: 0 }, to_y: { type: 'number', minimum: 0 }, duration_ms: { type: 'number', minimum: 80, maximum: 3000 } },
1272
+ required: ['interaction_id', 'observation_id', 'x', 'y', 'to_x', 'to_y'], additionalProperties: false,
1273
+ },
1274
+ },
1081
1275
  {
1082
1276
  actionId: 'physical_device_batch', title: 'Run fast physical iPhone input batch',
1083
- description: 'Preferred fast path for a visually confirmed target app: perform up to 20 tap, swipe, text, or bounded-wait steps under one fresh screenshot foreground fence, one unlock/display readiness pass, and one plugin round-trip. Pass observation_id to atomically bind and consume the latest visually verified screenshot without a separate confirm_foreground round-trip; omitting it preserves the explicit confirm_foreground compatibility path. Stops on the first failed step and never replays a partially-mutated batch.',
1277
+ description: 'Run up to 20 short visually grounded taps, swipes, text edits or waits on the single CoreDevice/HID provider. First independently inspect the screenshot and call physical_device_confirm_foreground with the exact target bundle. observation_id is an optional match check, not an authorization shortcut. capture_after=true returns the NEXT PNG and observation for separate inspection and confirmation. Never retry partially applied or unknown input.',
1084
1278
  readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
1085
1279
  scopes: ['ios.device'], resourceClaims: mutationClaims,
1086
1280
  argumentsSchema: {
@@ -1088,6 +1282,7 @@ export function iosPhysicalDeviceActions(): AssistantPluginActionDescriptor[] {
1088
1282
  properties: {
1089
1283
  ...interactionProperty,
1090
1284
  observation_id: { type: 'string' },
1285
+ capture_after: { type: 'boolean' },
1091
1286
  steps: {
1092
1287
  type: 'array', minItems: 1, maxItems: MAX_BATCH_STEPS,
1093
1288
  items: {
@@ -1146,6 +1341,20 @@ export function iosPhysicalDeviceActions(): AssistantPluginActionDescriptor[] {
1146
1341
  required: ['interaction_id', 'text'], additionalProperties: false,
1147
1342
  },
1148
1343
  },
1344
+ {
1345
+ actionId: 'physical_device_documents_list', title: 'Discover selected-app iPhone documents',
1346
+ description: 'With explicit interactive confirmation, list bounded PDF/image filenames in the selected app Documents sandbox. No general Files/iCloud or other-app search.',
1347
+ readOnly: false, risk: 'workspace_write', confirmation: 'strong_confirmation', requiredConfirmationText: 'list-iphone-app-documents',
1348
+ defaultTimeoutMs: 60_000, cancellable: true, idempotent: true, scopes: ['ios.device'], resourceClaims: stateClaim,
1349
+ argumentsSchema: { type: 'object', properties: { ...interactionProperty, bundle_id: { type: 'string' } }, required: ['interaction_id', 'bundle_id'], additionalProperties: false },
1350
+ },
1351
+ {
1352
+ actionId: 'physical_device_document_export', title: 'Export one selected-app iPhone document',
1353
+ description: 'With explicit interactive confirmation, copy one exact PDF/image from the selected app Documents sandbox into private Controller artifacts; no bulk export, deletion or upload.',
1354
+ readOnly: false, risk: 'workspace_write', confirmation: 'strong_confirmation', requiredConfirmationText: 'export-iphone-app-document',
1355
+ defaultTimeoutMs: 90_000, cancellable: true, idempotent: false, scopes: ['ios.device'], resourceClaims: mutationClaims,
1356
+ argumentsSchema: { type: 'object', properties: { ...interactionProperty, bundle_id: { type: 'string' }, path: { type: 'string', maxLength: 512 } }, required: ['interaction_id', 'bundle_id', 'path'], additionalProperties: false },
1357
+ },
1149
1358
  {
1150
1359
  actionId: 'physical_device_events', title: 'Read physical iOS events',
1151
1360
  description: 'Read bounded, redacted provider events without input text or credentials.',
@@ -1161,9 +1370,9 @@ export function iosPhysicalDeviceActions(): AssistantPluginActionDescriptor[] {
1161
1370
  argumentsSchema: { type: 'object', properties: { ...interactionProperty }, required: ['interaction_id'], additionalProperties: false },
1162
1371
  },
1163
1372
  ];
1164
- return actions.map((action) => action.readOnly || action.actionId === 'physical_device_confirm_foreground'
1373
+ return actions.map((action) => action.readOnly
1165
1374
  ? action
1166
- : { ...action, executionMode: 'lightweight_process' });
1375
+ : { ...action, executionMode: 'runtime' });
1167
1376
  }
1168
1377
 
1169
1378
  export async function executeIosPhysicalDeviceAction(input: AssistantPluginActionExecutionInput): Promise<Record<string, unknown>> {
@@ -1198,6 +1407,11 @@ export async function executeIosPhysicalDeviceAction(input: AssistantPluginActio
1198
1407
  const selectStartedAt = performance.now();
1199
1408
  const selected = await selectDevice(input, input.args.device);
1200
1409
  recordTiming(timingStages, 'deviceSelection', selectStartedAt, false);
1410
+ if (!selected.connected) {
1411
+ throw new AssistantPluginError('IOS_DEVICE_NOT_CONNECTED',
1412
+ 'The selected paired iPhone has no usable CoreDevice connection; app activation was not attempted.',
1413
+ { retryable: true });
1414
+ }
1201
1415
  const bundleId = requireBundleId(input.args.bundle_id);
1202
1416
  const appsStartedAt = performance.now();
1203
1417
  const lockStartedAt = performance.now();
@@ -1213,10 +1427,60 @@ export async function executeIosPhysicalDeviceAction(input: AssistantPluginActio
1213
1427
  const sessionLookupStartedAt = performance.now();
1214
1428
  const expiredSessionCount = expirePhysicalDeviceSessions(input);
1215
1429
  pruneInteractionSessions(input.repoRoot, PROVIDER, 100);
1430
+ const reusable = reusablePhysicalSession(input, selected, bundleId);
1216
1431
  const conflict = listInteractionSessions(input.repoRoot, PROVIDER).find((entry) =>
1217
1432
  isInteractionSessionActive(entry.status) && interactionMayOwnTarget(entry, selectedAliases));
1218
1433
  recordTiming(timingStages, 'sessionLookup', sessionLookupStartedAt, false);
1219
1434
  if (expiredSessionCount > 0) timingStages.sessionReconcile = { ms: timingStages.sessionLookup.ms, cached: false };
1435
+ if (reusable) {
1436
+ const ownership = ensureIosDeviceOwnership(input.controllerHome, input.repoId, reusable.interactionId, iosCoreDeviceOwnershipId(selected));
1437
+ if (!ownership.acquired) throw deviceOwnershipError(selected, ownership);
1438
+ const existingState = readState(input, reusable.interactionId);
1439
+ if (!existingState) {
1440
+ patchInteractionSession(input.repoRoot, PROVIDER, reusable.interactionId, {
1441
+ status: 'failed',
1442
+ error: { code: 'IOS_DEVICE_SESSION_STATE_MISSING', message: 'The existing physical iPhone session has no recoverable state.' },
1443
+ });
1444
+ appendEvent(input, reusable.interactionId, 'session_state_missing');
1445
+ releaseIosDeviceOwnership(input.controllerHome, input.repoId, reusable.interactionId, iosCoreDeviceOwnershipId(selected));
1446
+ throw new AssistantPluginError('IOS_DEVICE_SESSION_STATE_MISSING', 'The existing physical iPhone session has no recoverable state.', { retryable: false });
1447
+ }
1448
+ // Owning a session never proves the target app remains in the foreground.
1449
+ // Invalidate the old observation BEFORE activating another foreground app.
1450
+ existingState.lastScreenshot = undefined;
1451
+ existingState.foregroundObservation = undefined;
1452
+ writeState(input, existingState);
1453
+ const activationArgs = ['device', 'process', 'launch', '--device', selected.identifier, '--activate'];
1454
+ if (input.args.relaunch === true) activationArgs.push('--terminate-existing');
1455
+ activationArgs.push(bundleId);
1456
+ const activationStartedAt = performance.now();
1457
+ const activation = await runCoreJson(input, activationArgs, 'IOS_DEVICE_LAUNCH_FAILED', 60_000)
1458
+ .finally(() => recordTiming(timingStages, 'activationRequest', activationStartedAt, false));
1459
+ appendEvent(input, reusable.interactionId, 'app_activated', { bundleId, relaunch: input.args.relaunch === true });
1460
+ const active = patchInteractionSession(input.repoRoot, PROVIDER, reusable.interactionId, {
1461
+ expiresAt: new Date(hooks.now().getTime() + SESSION_EXPIRY_MS).toISOString(),
1462
+ }) ?? reusable;
1463
+ const nextObservation = input.args.observe_after_open === true
1464
+ ? await optionalPostActionCapture(input, existingState, 'resume', timingStages)
1465
+ : {};
1466
+ return finishTimedResult(actionStartedAt, timingStages, {
1467
+ ...nextObservation,
1468
+ provider: 'coredevice',
1469
+ interaction: active,
1470
+ device: selected,
1471
+ app: apps[0],
1472
+ launch: bounded(activation),
1473
+ foregroundVerification: { authority: 'screenshot_observation', required: true },
1474
+ reusedSession: true,
1475
+ controlPlane: {
1476
+ lifecycle: 'coredevice',
1477
+ observation: 'coredevice',
1478
+ input: 'remote-xpc-hid',
1479
+ semanticFallback: 'agent-device',
1480
+ runnerOwned: false,
1481
+ },
1482
+ });
1483
+ }
1220
1484
  if (conflict) {
1221
1485
  throw new AssistantPluginError('PLUGIN_RESOURCE_BUSY', 'The selected iPhone already has an active forge interaction.', {
1222
1486
  retryable: true,
@@ -1229,6 +1493,8 @@ export async function executeIosPhysicalDeviceAction(input: AssistantPluginActio
1229
1493
  });
1230
1494
  }
1231
1495
  const interactionId = `ios_device_${randomUUID()}`;
1496
+ const ownership = ensureIosDeviceOwnership(input.controllerHome, input.repoId, interactionId, iosCoreDeviceOwnershipId(selected));
1497
+ if (!ownership.acquired) throw deviceOwnershipError(selected, ownership);
1232
1498
  const createdAt = timestamp();
1233
1499
  const record: InteractionSessionRecord = {
1234
1500
  schemaVersion: 1,
@@ -1263,13 +1529,10 @@ export async function executeIosPhysicalDeviceAction(input: AssistantPluginActio
1263
1529
  if (input.args.relaunch === true) args.push('--terminate-existing');
1264
1530
  args.push(bundleId);
1265
1531
  const prewarmStartedAt = performance.now();
1266
- const prewarmRequested = input.args.prewarm_input === true && Boolean(selected.udid);
1267
- const inputPrewarm: Record<string, unknown> = prewarmRequested && selected.udid
1268
- ? prewarmRemoteXpcHid({
1269
- controllerHome: input.controllerHome,
1270
- deviceIdentifier: selected.identifier,
1271
- udid: selected.udid,
1272
- })
1532
+ // Separate plugin actions run in isolated processes. Starting a HID worker
1533
+ // here cannot accelerate the next input; defer startup to the input action.
1534
+ const inputPrewarm: Record<string, unknown> = input.args.prewarm_input === true
1535
+ ? { backend: 'remote-xpc-hid', state: 'deferred_to_first_input', runnerOwned: false }
1273
1536
  : { backend: 'remote-xpc-hid', state: 'not_requested', runnerOwned: false };
1274
1537
  recordTiming(
1275
1538
  timingStages,
@@ -1278,7 +1541,7 @@ export async function executeIosPhysicalDeviceAction(input: AssistantPluginActio
1278
1541
  inputPrewarm.state === 'ready' || inputPrewarm.state === 'test' || input.args.prewarm_input !== true,
1279
1542
  );
1280
1543
  const displayStartedAt = performance.now();
1281
- const display = input.args.prewarm_input === true
1544
+ const display = input.args.prewarm_input === true && input.args.observe_after_open !== true
1282
1545
  ? await physicalDisplayGeometry(input, selected).then((result) => {
1283
1546
  recordTiming(timingStages, 'displayInfo', displayStartedAt, false);
1284
1547
  return result;
@@ -1287,7 +1550,7 @@ export async function executeIosPhysicalDeviceAction(input: AssistantPluginActio
1287
1550
  throw error;
1288
1551
  })
1289
1552
  : undefined;
1290
- if (input.args.prewarm_input !== true) recordTiming(timingStages, 'displayInfo', displayStartedAt, true);
1553
+ if (input.args.prewarm_input !== true || input.args.observe_after_open === true) recordTiming(timingStages, 'displayInfo', displayStartedAt, true);
1291
1554
  if (display) {
1292
1555
  state.display = { ...display, observedAt: createdAt };
1293
1556
  writeState(input, state);
@@ -1312,7 +1575,11 @@ export async function executeIosPhysicalDeviceAction(input: AssistantPluginActio
1312
1575
  // Semantic automation is an explicit opt-in action so repeated computer-use
1313
1576
  // workflows cannot accidentally create fresh Runner lifecycles.
1314
1577
  const active = patchInteractionSession(input.repoRoot, PROVIDER, interactionId, { status: 'waiting_for_user' }) ?? record;
1578
+ const nextObservation = input.args.observe_after_open === true
1579
+ ? await optionalPostActionCapture(input, state, 'open', timingStages)
1580
+ : {};
1315
1581
  return finishTimedResult(actionStartedAt, timingStages, {
1582
+ ...nextObservation,
1316
1583
  provider: 'coredevice',
1317
1584
  interaction: active,
1318
1585
  device: selected,
@@ -1344,6 +1611,7 @@ export async function executeIosPhysicalDeviceAction(input: AssistantPluginActio
1344
1611
  error: { code: normalized.code, message: normalized.message },
1345
1612
  });
1346
1613
  appendEvent(input, interactionId, 'open_failed', { code: normalized.code, message: normalized.message });
1614
+ releaseIosDeviceOwnership(input.controllerHome, input.repoId, interactionId, iosCoreDeviceOwnershipId(selected));
1347
1615
  throw normalized;
1348
1616
  }
1349
1617
  }
@@ -1352,36 +1620,168 @@ export async function executeIosPhysicalDeviceAction(input: AssistantPluginActio
1352
1620
  const { record, state } = requireRecord(input, input.actionId === 'physical_device_close');
1353
1621
  recordTiming(timingStages, 'sessionLookup', sessionLookupStartedAt, false);
1354
1622
  switch (input.actionId) {
1355
- case 'physical_device_screenshot': {
1356
- const label = sanitize(optionalString(input.args.label) ?? 'screenshot');
1357
- const path = join(artifactDir(input, record.interactionId), `${label}-${hooks.now().getTime()}.png`);
1358
- const result = await runCoreJson(input, [
1359
- 'device', 'capture', 'screenshot', '--device', state.device.identifier, '--destination', path,
1360
- ], 'IOS_DEVICE_SCREENSHOT_FAILED', 60_000);
1361
- if (!existsSync(path)) {
1362
- throw new AssistantPluginError('IOS_DEVICE_SCREENSHOT_MISSING', 'CoreDevice succeeded without creating the requested screenshot.', { retryable: false });
1623
+ case 'physical_device_documents_list': {
1624
+ if (requireBundleId(input.args.bundle_id) !== state.bundleId) {
1625
+ throw new AssistantPluginError('IOS_DEVICE_DOCUMENT_TARGET_MISMATCH', 'Document consent does not match the exact app session.', { retryable: false });
1363
1626
  }
1364
- const observedAt = timestamp();
1365
- const observationId = randomUUID();
1366
- const screenshotDisplay = pngDisplayGeometry(path, state.display?.pointScale);
1367
- if (screenshotDisplay) state.display = { ...screenshotDisplay, observedAt };
1368
- state.lastScreenshot = { observationId, observedAt, label };
1369
- state.foregroundObservation = undefined;
1370
- writeState(input, state);
1371
- const eventStartedAt = performance.now();
1372
- appendEvent(input, record.interactionId, 'screenshot', { label, observationId });
1373
- recordTiming(timingStages, 'eventPersistence', eventStartedAt, false);
1627
+ if (!state.device.connected) {
1628
+ throw new AssistantPluginError('IOS_DEVICE_NOT_CONNECTED', 'Exact iPhone is not connected.', { retryable: true });
1629
+ }
1630
+ let response: Record<string, unknown>;
1631
+ try {
1632
+ response = await runCoreJson(input, [
1633
+ 'device', 'info', 'files', '--device', state.device.identifier,
1634
+ '--domain-type', 'appDataContainer', '--domain-identifier', state.bundleId,
1635
+ '--subdirectory', 'Documents', '--no-recurse',
1636
+ ], 'IOS_DEVICE_DOCUMENT_LIST_FAILED', 60_000);
1637
+ } catch (error) {
1638
+ // CoreDevice's raw errors may contain private document names or paths.
1639
+ // Keep the stable failure code, not stderr, stdout or command arguments.
1640
+ throw new AssistantPluginError('IOS_DEVICE_DOCUMENT_LIST_UNVERIFIED',
1641
+ 'The selected app document listing could not be verified.',
1642
+ { retryable: false, details: {
1643
+ causeCode: error instanceof AssistantPluginError ? error.code : 'COREDEVICE_LIST_FAILURE',
1644
+ } });
1645
+ }
1646
+ const result = objectValue(response.result);
1647
+ const entries = Array.isArray(result.files) ? result.files
1648
+ : Array.isArray(result.entries) ? result.entries : undefined;
1649
+ if (!entries) {
1650
+ throw new AssistantPluginError('IOS_DEVICE_DOCUMENT_LIST_SCHEMA_UNKNOWN',
1651
+ 'CoreDevice did not return a recognized file listing; no documents were verified.',
1652
+ { retryable: false });
1653
+ }
1654
+ // A recognized top-level array with unrecognized row fields is not an
1655
+ // empty directory. Reject it rather than falsely reporting no documents.
1656
+ const listedNames = entries.map((entry) => typeof entry === 'string' ? entry
1657
+ : stringField(objectValue(entry), 'name') ?? stringField(objectValue(entry), 'fileName'));
1658
+ if (listedNames.some((name) => typeof name !== 'string' || !name.trim())) {
1659
+ throw new AssistantPluginError('IOS_DEVICE_DOCUMENT_LIST_SCHEMA_UNKNOWN',
1660
+ 'CoreDevice returned file entries without recognizable names; document presence is unverified.',
1661
+ { retryable: false });
1662
+ }
1663
+ const names = (listedNames as string[]).filter((name) =>
1664
+ !name.includes('/') && !name.includes('\\')
1665
+ && /\.(?:pdf|png|jpe?g|heic)$/i.test(name)).slice(0, 100);
1666
+ return { provider: 'coredevice', deviceUnmodified: true,
1667
+ interactionId: record.interactionId, bundleId: state.bundleId,
1668
+ domain: 'appDataContainer/Documents', names, truncated: entries.length > 100 };
1669
+ }
1670
+ case 'physical_device_document_export': {
1671
+ if (requireBundleId(input.args.bundle_id) !== state.bundleId) {
1672
+ throw new AssistantPluginError('IOS_DEVICE_DOCUMENT_TARGET_MISMATCH', 'Document consent does not match the exact app session.', { retryable: false });
1673
+ }
1674
+ if (!state.device.connected) {
1675
+ throw new AssistantPluginError('IOS_DEVICE_NOT_CONNECTED', 'Exact iPhone is not connected.', { retryable: true });
1676
+ }
1677
+ const path = requireAppDocumentPath(input.args.path);
1678
+ const dir = join(artifactDir(input, record.interactionId), 'private-documents');
1679
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
1680
+ if (!lstatSync(dir).isDirectory()) {
1681
+ throw new AssistantPluginError('IOS_DEVICE_DOCUMENT_ARTIFACT_INVALID', 'The private document directory is not a regular directory.', { retryable: false });
1682
+ }
1683
+ chmodSync(dir, 0o700);
1684
+ const ext = path.slice(path.lastIndexOf('.')).toLowerCase();
1685
+ const destination = join(dir, randomUUID() + ext);
1686
+ try {
1687
+ // Validate the existing manifest before copying anything from the
1688
+ // phone. Corrupt, oversized or foreign records cannot be overwritten.
1689
+ const indexPath = join(dir, 'index.json');
1690
+ let entries: Array<Record<string, unknown>> = [];
1691
+ if (existsSync(indexPath)) {
1692
+ const indexFile = lstatSync(indexPath);
1693
+ if (!indexFile.isFile() || indexFile.size <= 0 || indexFile.size > MAX_DOCUMENT_INDEX_BYTES) {
1694
+ throw new AssistantPluginError('IOS_DEVICE_DOCUMENT_INDEX_INVALID',
1695
+ 'The private document index is not a bounded regular file.', { retryable: false });
1696
+ }
1697
+ const prior = readJsonFile<unknown>(indexPath);
1698
+ const index = objectValue(prior);
1699
+ const stored = index.entries;
1700
+ const validEntries = Array.isArray(stored) && stored.length <= 500
1701
+ && stored.every((item) => {
1702
+ const row = objectValue(item);
1703
+ return typeof row.exportedAt === 'string' && !Number.isNaN(Date.parse(row.exportedAt))
1704
+ && typeof row.sourcePath === 'string' && /^Documents\/[^/\\]+\.(?:pdf|png|jpe?g|heic)$/i.test(row.sourcePath)
1705
+ && typeof row.artifactName === 'string' && /^[a-f0-9-]{36}\.(?:pdf|png|jpe?g|heic)$/i.test(row.artifactName)
1706
+ && typeof row.sha256 === 'string' && /^[a-f0-9]{64}$/.test(row.sha256)
1707
+ && typeof row.bytes === 'number' && Number.isSafeInteger(row.bytes)
1708
+ && row.bytes > 0 && row.bytes <= MAX_EXPORTED_DOCUMENT_BYTES;
1709
+ });
1710
+ if (index.schemaVersion !== 1 || index.bundleId !== state.bundleId
1711
+ || index.interactionId !== record.interactionId || !validEntries) {
1712
+ throw new AssistantPluginError('IOS_DEVICE_DOCUMENT_INDEX_INVALID',
1713
+ 'The private document index belongs to another session or is malformed.', { retryable: false });
1714
+ }
1715
+ entries = stored as Array<Record<string, unknown>>;
1716
+ }
1717
+ if (entries.length >= 500) {
1718
+ throw new AssistantPluginError('IOS_DEVICE_DOCUMENT_INDEX_FULL',
1719
+ 'The private document index has reached its 500-item limit.', { retryable: false });
1720
+ }
1721
+ await runCoreJson(input, [
1722
+ 'device', 'copy', 'from', '--device', state.device.identifier,
1723
+ '--domain-type', 'appDataContainer', '--domain-identifier', state.bundleId,
1724
+ '--source', path, '--destination', destination,
1725
+ ], 'IOS_DEVICE_DOCUMENT_EXPORT_FAILED', 90_000);
1726
+ const file = lstatSync(destination);
1727
+ if (!file.isFile() || file.size <= 0 || file.size > MAX_EXPORTED_DOCUMENT_BYTES) {
1728
+ throw new AssistantPluginError('IOS_DEVICE_DOCUMENT_EXPORT_INVALID',
1729
+ 'Expected exactly one nonempty regular document of at most 20 MiB.', { retryable: false });
1730
+ }
1731
+ chmodSync(destination, 0o600);
1732
+ const bytes = readFileSync(destination);
1733
+ // Validate more than magic bytes: a truncated CoreDevice copy must not
1734
+ // become a successful medical-document export or organized index entry.
1735
+ const isExpectedFormat = ext === '.pdf'
1736
+ ? bytes.subarray(0, 5).toString('ascii') === '%PDF-'
1737
+ && /%%EOF[\x00\x09\x0a\x0c\x0d\x20]*$/.test(bytes.subarray(-2048).toString('latin1'))
1738
+ : ext === '.png' ? pngDisplayGeometry(bytes) !== undefined
1739
+ : ext === '.jpg' || ext === '.jpeg'
1740
+ ? bytes.length > 4 && bytes.subarray(0, 3).toString('hex') === 'ffd8ff'
1741
+ && bytes.subarray(-2).toString('hex') === 'ffd9'
1742
+ : ext === '.heic' && bytes.length >= 16
1743
+ && bytes.subarray(4, 8).toString('ascii') === 'ftyp'
1744
+ && /^(?:heic|heix|hevc|hevx|mif1|msf1)$/.test(bytes.subarray(8, 12).toString('ascii'))
1745
+ && bytes.readUInt32BE(0) >= 16 && bytes.readUInt32BE(0) <= bytes.length;
1746
+ if (!isExpectedFormat) {
1747
+ throw new AssistantPluginError('IOS_DEVICE_DOCUMENT_FORMAT_INVALID',
1748
+ 'Exported document framing is invalid or incomplete for the selected file type.', { retryable: false });
1749
+ }
1750
+ const sha256 = createHash('sha256').update(bytes).digest('hex');
1751
+ // One canonical export writer also maintains a bounded private index of
1752
+ // facts; medical meaning and cross-app classification stay with the model.
1753
+ const entry = { exportedAt: timestamp(), sourcePath: path,
1754
+ artifactName: destination.slice(dir.length + 1), sha256, bytes: file.size };
1755
+ // Atomic rename is the last fallible operation before acknowledging the
1756
+ // export; a failed index update cannot produce a successful receipt.
1757
+ writeJsonAtomic(indexPath, { schemaVersion: 1, bundleId: state.bundleId,
1758
+ interactionId: record.interactionId, entries: [...entries, entry] }, { mode: 0o600 });
1759
+ return { provider: 'coredevice', deviceUnmodified: true, externallyUploaded: false,
1760
+ interactionId: record.interactionId, bundleId: state.bundleId,
1761
+ document: { domain: 'appDataContainer', sourcePath: path, localArtifact: destination,
1762
+ sha256, bytes: file.size },
1763
+ organizedIndex: { localArtifact: indexPath, entryCount: entries.length + 1 } };
1764
+ } catch (error) {
1765
+ // Raw CoreDevice error details may contain sensitive document filenames.
1766
+ // Never expose those details or retry an unknown/partial copy.
1767
+ let cleanup = 'no_partial_artifact';
1768
+ if (existsSync(destination)) {
1769
+ try { unlinkSync(destination); cleanup = 'partial_artifact_deleted'; }
1770
+ catch { cleanup = 'partial_artifact_cleanup_failed'; }
1771
+ }
1772
+ throw new AssistantPluginError('IOS_DEVICE_DOCUMENT_EXPORT_UNVERIFIED',
1773
+ 'The selected document export was not verified. Input is not replayable.',
1774
+ { retryable: false, details: {
1775
+ causeCode: error instanceof AssistantPluginError ? error.code : 'FILE_VERIFICATION_FAILED',
1776
+ cleanup,
1777
+ } });
1778
+ }
1779
+ }
1780
+ case 'physical_device_screenshot': {
1781
+ const captured = await capturePhysicalScreen(input, state, sanitize(optionalString(input.args.label) ?? 'screenshot'));
1782
+ const { screenshotResult, ...observation } = captured;
1374
1783
  return finishTimedResult(actionStartedAt, timingStages, {
1375
- provider: 'coredevice', interaction: record, result: bounded(result),
1376
- observation: { observationId, observedAt, label, ttlMs: INPUT_FOREGROUND_OBSERVATION_TTL_MS },
1377
- fastPath: {
1378
- actionId: 'physical_device_batch',
1379
- arguments: { interaction_id: record.interactionId, observation_id: observationId },
1380
- requires: ['steps'],
1381
- ttlMs: INPUT_FOREGROUND_OBSERVATION_TTL_MS,
1382
- oneShot: true,
1383
- },
1384
- artifactCandidates: [{ kind: 'ios_physical_device_screenshot', mediaType: 'image/png', path }],
1784
+ provider: 'coredevice', interaction: record, result: screenshotResult, ...observation,
1385
1785
  });
1386
1786
  }
1387
1787
  case 'physical_device_confirm_foreground': {
@@ -1410,7 +1810,8 @@ export async function executeIosPhysicalDeviceAction(input: AssistantPluginActio
1410
1810
  deviceUnmodified: true,
1411
1811
  };
1412
1812
  }
1413
- case 'physical_device_observed_tap': {
1813
+ case 'physical_device_observed_tap':
1814
+ case 'physical_device_observed_swipe': {
1414
1815
  const lockStartedAt = performance.now();
1415
1816
  const unlock = await requireSessionUnlocked(input, state);
1416
1817
  recordTiming(timingStages, 'lockState', lockStartedAt, unlock.cached);
@@ -1425,17 +1826,29 @@ export async function executeIosPhysicalDeviceAction(input: AssistantPluginActio
1425
1826
  const display = geometry.display;
1426
1827
  const x = requireFiniteNumber(input.args.x, 'x');
1427
1828
  const y = requireFiniteNumber(input.args.y, 'y');
1829
+ const swipe = input.actionId === 'physical_device_observed_swipe';
1830
+ const x2 = swipe ? requireFiniteNumber(input.args.to_x, 'to_x') : undefined;
1831
+ const y2 = swipe ? requireFiniteNumber(input.args.to_y, 'to_y') : undefined;
1832
+ const durationMs = swipe && typeof input.args.duration_ms === 'number'
1833
+ ? Math.trunc(input.args.duration_ms) : 250;
1428
1834
  consumePhysicalScreenObservation(input, state);
1429
1835
  const hidStartedAt = performance.now();
1430
1836
  const result = await executeRemoteXpcHidInput({
1431
1837
  controllerHome: input.controllerHome, deviceIdentifier: state.device.identifier, udid: state.device.udid,
1432
- width: display.width, height: display.height, action: 'tap', x, y,
1838
+ width: display.width, height: display.height, action: swipe ? 'swipe' : 'tap',
1839
+ x, y, ...(swipe ? { x2, y2, durationMs } : {}),
1433
1840
  });
1434
1841
  timingStages.hidWorkerReady = { ms: result.timings.workerStartupMs + result.timings.workerReadyMs, cached: result.reusedWorker };
1435
1842
  timingStages.hidWriteAck = { ms: result.timings.hidMs ?? result.timings.requestMs, cached: false };
1436
1843
  timingStages.hidRequestTotal = { ms: elapsedMs(hidStartedAt), cached: result.reusedWorker };
1437
- appendEvent(input, record.interactionId, 'observed_tap', { requestId: input.requestId, observationId: observation.observationId, observationAgeMs: ageMs, x, y, reusedWorker: result.reusedWorker });
1438
- return finishTimedResult(actionStartedAt, timingStages, { provider: 'coredevice', inputBackend: 'remote-xpc-hid', interaction: record, display, result: bounded(result), observationConsumed: true });
1844
+ appendEvent(input, record.interactionId, swipe ? 'observed_swipe' : 'observed_tap', {
1845
+ requestId: input.requestId, observationId: observation.observationId, observationAgeMs: ageMs,
1846
+ x, y, ...(swipe ? { x2, y2, durationMs } : {}), reusedWorker: result.reusedWorker,
1847
+ });
1848
+ const nextObservation = input.args.capture_after === true
1849
+ ? await optionalPostActionCapture(input, state, swipe ? 'swipe' : 'tap', timingStages)
1850
+ : {};
1851
+ return finishTimedResult(actionStartedAt, timingStages, { ...nextObservation, provider: 'coredevice', inputBackend: 'remote-xpc-hid', interaction: record, display, result: bounded(result), observationConsumed: true });
1439
1852
  }
1440
1853
  case 'physical_device_batch': {
1441
1854
  const lockStartedAt = performance.now();
@@ -1568,7 +1981,11 @@ export async function executeIosPhysicalDeviceAction(input: AssistantPluginActio
1568
1981
  kinds: steps.map((step) => step.kind),
1569
1982
  });
1570
1983
  recordTiming(timingStages, 'eventPersistence', eventStartedAt, false);
1984
+ const nextObservation = input.args.capture_after === true
1985
+ ? await optionalPostActionCapture(input, state, 'batch', timingStages)
1986
+ : {};
1571
1987
  return finishTimedResult(actionStartedAt, timingStages, {
1988
+ ...nextObservation,
1572
1989
  provider: 'coredevice',
1573
1990
  inputBackend: 'remote-xpc-hid',
1574
1991
  interaction: record,
@@ -1698,7 +2115,8 @@ export async function executeIosPhysicalDeviceAction(input: AssistantPluginActio
1698
2115
  patchInteractionSession(input.repoRoot, PROVIDER, record.interactionId, { status: 'closing' });
1699
2116
  appendEvent(input, record.interactionId, 'session_closed', { inputWorkerRelease });
1700
2117
  const finalRecord = patchInteractionSession(input.repoRoot, PROVIDER, record.interactionId, { status: 'closed', error: undefined }) ?? record;
1701
- return { provider: 'coredevice', interaction: finalRecord, deviceUnmodified: true, inputWorkerRelease };
2118
+ const deviceOwnershipReleased = releaseIosDeviceOwnership(input.controllerHome, input.repoId, record.interactionId, iosCoreDeviceOwnershipId(state.device));
2119
+ return { provider: 'coredevice', interaction: finalRecord, deviceUnmodified: true, inputWorkerRelease, deviceOwnershipReleased };
1702
2120
  }
1703
2121
  default:
1704
2122
  throw new AssistantPluginError('PLUGIN_ACTION_NOT_SUPPORTED', `ios/${input.actionId} is not supported.`, { retryable: false });