@team-agent/installer 0.2.11 → 0.3.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.
Files changed (326) hide show
  1. package/Cargo.lock +744 -0
  2. package/Cargo.toml +34 -0
  3. package/crates/team-agent/Cargo.toml +33 -0
  4. package/crates/team-agent/src/cli/adapters.rs +1343 -0
  5. package/crates/team-agent/src/cli/diagnose.rs +554 -0
  6. package/crates/team-agent/src/cli/emit.rs +1204 -0
  7. package/crates/team-agent/src/cli/helpers.rs +88 -0
  8. package/crates/team-agent/src/cli/leader.rs +216 -0
  9. package/crates/team-agent/src/cli/mod.rs +1207 -0
  10. package/crates/team-agent/src/cli/profile.rs +306 -0
  11. package/crates/team-agent/src/cli/send.rs +215 -0
  12. package/crates/team-agent/src/cli/status.rs +179 -0
  13. package/crates/team-agent/src/cli/status_port.rs +502 -0
  14. package/crates/team-agent/src/cli/tests/base.rs +616 -0
  15. package/crates/team-agent/src/cli/tests/compile.rs +96 -0
  16. package/crates/team-agent/src/cli/tests/divergence.rs +509 -0
  17. package/crates/team-agent/src/cli/tests/lane_c.rs +333 -0
  18. package/crates/team-agent/src/cli/tests/leader_watch.rs +395 -0
  19. package/crates/team-agent/src/cli/tests/main_preserved.rs +675 -0
  20. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +390 -0
  21. package/crates/team-agent/src/cli/tests/mod.rs +97 -0
  22. package/crates/team-agent/src/cli/tests/peer_allow.rs +137 -0
  23. package/crates/team-agent/src/cli/tests/repair_state_byte_lock.rs +302 -0
  24. package/crates/team-agent/src/cli/tests/run_delegation.rs +305 -0
  25. package/crates/team-agent/src/cli/tests/status_send.rs +385 -0
  26. package/crates/team-agent/src/cli/tests/verb_profile.rs +182 -0
  27. package/crates/team-agent/src/cli/tests/verb_settle.rs +236 -0
  28. package/crates/team-agent/src/cli/tests/verb_validate.rs +184 -0
  29. package/crates/team-agent/src/cli/types.rs +605 -0
  30. package/crates/team-agent/src/compiler/tests.rs +701 -0
  31. package/crates/team-agent/src/compiler.rs +489 -0
  32. package/crates/team-agent/src/coordinator/backoff.rs +153 -0
  33. package/crates/team-agent/src/coordinator/health.rs +557 -0
  34. package/crates/team-agent/src/coordinator/mod.rs +80 -0
  35. package/crates/team-agent/src/coordinator/orphan.rs +179 -0
  36. package/crates/team-agent/src/coordinator/tests/abnormal.rs +255 -0
  37. package/crates/team-agent/src/coordinator/tests/basics.rs +262 -0
  38. package/crates/team-agent/src/coordinator/tests/daemon.rs +323 -0
  39. package/crates/team-agent/src/coordinator/tests/health_sync.rs +263 -0
  40. package/crates/team-agent/src/coordinator/tests/main_preserved.rs +136 -0
  41. package/crates/team-agent/src/coordinator/tests/mod.rs +310 -0
  42. package/crates/team-agent/src/coordinator/tests/spine.rs +261 -0
  43. package/crates/team-agent/src/coordinator/tests/takeover.rs +227 -0
  44. package/crates/team-agent/src/coordinator/tests/tick_core.rs +256 -0
  45. package/crates/team-agent/src/coordinator/tests/watch.rs +167 -0
  46. package/crates/team-agent/src/coordinator/tick.rs +2032 -0
  47. package/crates/team-agent/src/coordinator/types.rs +584 -0
  48. package/crates/team-agent/src/db/migration.rs +716 -0
  49. package/crates/team-agent/src/db/mod.rs +23 -0
  50. package/crates/team-agent/src/db/schema.rs +378 -0
  51. package/crates/team-agent/src/event_log.rs +375 -0
  52. package/crates/team-agent/src/fake_worker.rs +253 -0
  53. package/crates/team-agent/src/leader/helpers.rs +190 -0
  54. package/crates/team-agent/src/leader/inject.rs +33 -0
  55. package/crates/team-agent/src/leader/lease.rs +1084 -0
  56. package/crates/team-agent/src/leader/mod.rs +99 -0
  57. package/crates/team-agent/src/leader/owner_bind.rs +292 -0
  58. package/crates/team-agent/src/leader/rediscover/tests.rs +526 -0
  59. package/crates/team-agent/src/leader/rediscover.rs +1101 -0
  60. package/crates/team-agent/src/leader/start.rs +273 -0
  61. package/crates/team-agent/src/leader/takeover.rs +235 -0
  62. package/crates/team-agent/src/leader/tests/basics.rs +183 -0
  63. package/crates/team-agent/src/leader/tests/byte_findings.rs +237 -0
  64. package/crates/team-agent/src/leader/tests/identity.rs +206 -0
  65. package/crates/team-agent/src/leader/tests/idle.rs +272 -0
  66. package/crates/team-agent/src/leader/tests/lease_api.rs +225 -0
  67. package/crates/team-agent/src/leader/tests/lease_claim.rs +410 -0
  68. package/crates/team-agent/src/leader/tests/mod.rs +125 -0
  69. package/crates/team-agent/src/leader/tests/rediscover.rs +351 -0
  70. package/crates/team-agent/src/leader/tests/wake_start_owner.rs +204 -0
  71. package/crates/team-agent/src/leader/types.rs +489 -0
  72. package/crates/team-agent/src/lib.rs +85 -0
  73. package/crates/team-agent/src/lifecycle/display.rs +228 -0
  74. package/crates/team-agent/src/lifecycle/helpers.rs +112 -0
  75. package/crates/team-agent/src/lifecycle/launch/plan.rs +227 -0
  76. package/crates/team-agent/src/lifecycle/launch.rs +2109 -0
  77. package/crates/team-agent/src/lifecycle/mod.rs +62 -0
  78. package/crates/team-agent/src/lifecycle/restart/agent.rs +533 -0
  79. package/crates/team-agent/src/lifecycle/restart/common.rs +517 -0
  80. package/crates/team-agent/src/lifecycle/restart/orchestrator.rs +41 -0
  81. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +268 -0
  82. package/crates/team-agent/src/lifecycle/restart/remove.rs +780 -0
  83. package/crates/team-agent/src/lifecycle/restart/selection.rs +208 -0
  84. package/crates/team-agent/src/lifecycle/restart/team_state.rs +242 -0
  85. package/crates/team-agent/src/lifecycle/restart.rs +76 -0
  86. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +455 -0
  87. package/crates/team-agent/src/lifecycle/tests/core.rs +989 -0
  88. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +583 -0
  89. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +985 -0
  90. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +265 -0
  91. package/crates/team-agent/src/lifecycle/tests.rs +27 -0
  92. package/crates/team-agent/src/lifecycle/types.rs +710 -0
  93. package/crates/team-agent/src/main.rs +41 -0
  94. package/crates/team-agent/src/mcp_server/helpers.rs +228 -0
  95. package/crates/team-agent/src/mcp_server/mod.rs +183 -0
  96. package/crates/team-agent/src/mcp_server/normalize.rs +312 -0
  97. package/crates/team-agent/src/mcp_server/tests/golden.rs +283 -0
  98. package/crates/team-agent/src/mcp_server/tests/normalize.rs +244 -0
  99. package/crates/team-agent/src/mcp_server/tests/scoped.rs +189 -0
  100. package/crates/team-agent/src/mcp_server/tests/send.rs +222 -0
  101. package/crates/team-agent/src/mcp_server/tests/tools.rs +158 -0
  102. package/crates/team-agent/src/mcp_server/tests/wire.rs +187 -0
  103. package/crates/team-agent/src/mcp_server/tests.rs +38 -0
  104. package/crates/team-agent/src/mcp_server/tools.rs +603 -0
  105. package/crates/team-agent/src/mcp_server/types.rs +421 -0
  106. package/crates/team-agent/src/mcp_server/wire.rs +468 -0
  107. package/crates/team-agent/src/message_store.rs +767 -0
  108. package/crates/team-agent/src/messaging/activity.rs +433 -0
  109. package/crates/team-agent/src/messaging/delivery.rs +743 -0
  110. package/crates/team-agent/src/messaging/helpers.rs +209 -0
  111. package/crates/team-agent/src/messaging/leader_receiver.rs +329 -0
  112. package/crates/team-agent/src/messaging/mod.rs +147 -0
  113. package/crates/team-agent/src/messaging/peers.rs +32 -0
  114. package/crates/team-agent/src/messaging/results.rs +553 -0
  115. package/crates/team-agent/src/messaging/scheduler.rs +344 -0
  116. package/crates/team-agent/src/messaging/selftest.rs +100 -0
  117. package/crates/team-agent/src/messaging/send.rs +578 -0
  118. package/crates/team-agent/src/messaging/tests/basic.rs +357 -0
  119. package/crates/team-agent/src/messaging/tests/main_preserved.rs +122 -0
  120. package/crates/team-agent/src/messaging/tests/mod.rs +293 -0
  121. package/crates/team-agent/src/messaging/tests/runtime.rs +1422 -0
  122. package/crates/team-agent/src/messaging/tests/spine.rs +437 -0
  123. package/crates/team-agent/src/messaging/trust.rs +192 -0
  124. package/crates/team-agent/src/messaging/types.rs +355 -0
  125. package/crates/team-agent/src/messaging/watchers.rs +591 -0
  126. package/crates/team-agent/src/model/enums.rs +311 -0
  127. package/crates/team-agent/src/model/errors.rs +17 -0
  128. package/crates/team-agent/src/model/ids.rs +155 -0
  129. package/crates/team-agent/src/model/mod.rs +22 -0
  130. package/crates/team-agent/src/model/paths.rs +228 -0
  131. package/crates/team-agent/src/model/permissions.rs +567 -0
  132. package/crates/team-agent/src/model/routing.rs +340 -0
  133. package/crates/team-agent/src/model/spec.rs +680 -0
  134. package/crates/team-agent/src/model/task_graph.rs +380 -0
  135. package/crates/team-agent/src/model/testdata/fuzz.golden.yaml +43 -0
  136. package/crates/team-agent/src/model/testdata/fuzz.yaml +43 -0
  137. package/crates/team-agent/src/model/testdata/spec_invalid_a.yaml +207 -0
  138. package/crates/team-agent/src/model/testdata/team.spec.golden.yaml +206 -0
  139. package/crates/team-agent/src/model/testdata/team.spec.yaml +206 -0
  140. package/crates/team-agent/src/model/yaml/tests.rs +288 -0
  141. package/crates/team-agent/src/model/yaml.rs +800 -0
  142. package/crates/team-agent/src/packaging/install.rs +305 -0
  143. package/crates/team-agent/src/packaging/migrate.rs +30 -0
  144. package/crates/team-agent/src/packaging/mod.rs +82 -0
  145. package/crates/team-agent/src/packaging/repair.rs +24 -0
  146. package/crates/team-agent/src/packaging/tests.rs +829 -0
  147. package/crates/team-agent/src/packaging/types.rs +369 -0
  148. package/crates/team-agent/src/provider/adapter.rs +801 -0
  149. package/crates/team-agent/src/provider/approvals/mod.rs +2 -0
  150. package/crates/team-agent/src/provider/approvals/parsing.rs +452 -0
  151. package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +163 -0
  152. package/crates/team-agent/src/provider/classify.rs +456 -0
  153. package/crates/team-agent/src/provider/faults.rs +136 -0
  154. package/crates/team-agent/src/provider/helpers.rs +41 -0
  155. package/crates/team-agent/src/provider/mod.rs +53 -0
  156. package/crates/team-agent/src/provider/startup_prompt.rs +423 -0
  157. package/crates/team-agent/src/provider/tests/adapter.rs +239 -0
  158. package/crates/team-agent/src/provider/tests/classify.rs +240 -0
  159. package/crates/team-agent/src/provider/tests/faults.rs +120 -0
  160. package/crates/team-agent/src/provider/tests/idle.rs +208 -0
  161. package/crates/team-agent/src/provider/tests/wire.rs +213 -0
  162. package/crates/team-agent/src/provider/tests.rs +31 -0
  163. package/crates/team-agent/src/provider/types.rs +424 -0
  164. package/crates/team-agent/src/state/identity.rs +659 -0
  165. package/crates/team-agent/src/state/mod.rs +58 -0
  166. package/crates/team-agent/src/state/owner_gate.rs +423 -0
  167. package/crates/team-agent/src/state/persist.rs +712 -0
  168. package/crates/team-agent/src/state/projection.rs +657 -0
  169. package/crates/team-agent/src/state/selector.rs +105 -0
  170. package/crates/team-agent/src/state/testdata/state-rich.canonical.json +133 -0
  171. package/crates/team-agent/src/tmux_backend/tests.rs +765 -0
  172. package/crates/team-agent/src/tmux_backend.rs +810 -0
  173. package/crates/team-agent/src/transport/test_support.rs +252 -0
  174. package/crates/team-agent/src/transport/tests/behavior.rs +327 -0
  175. package/crates/team-agent/src/transport/tests/mod.rs +199 -0
  176. package/crates/team-agent/src/transport/tests/wire.rs +527 -0
  177. package/crates/team-agent/src/transport.rs +774 -0
  178. package/npm/install.mjs +118 -112
  179. package/package.json +15 -13
  180. package/crates/team-agent-core/Cargo.toml +0 -12
  181. package/crates/team-agent-core/src/lib.rs +0 -332
  182. package/crates/team-agent-core/src/main.rs +0 -152
  183. package/pyproject.toml +0 -18
  184. package/scripts/install.py +0 -88
  185. package/scripts/run_regression_tests.py +0 -83
  186. package/src/team_agent/__init__.py +0 -3
  187. package/src/team_agent/__main__.py +0 -5
  188. package/src/team_agent/_legacy_pane_discovery.py +0 -186
  189. package/src/team_agent/abnormal_track.py +0 -253
  190. package/src/team_agent/approvals/__init__.py +0 -65
  191. package/src/team_agent/approvals/constants.py +0 -6
  192. package/src/team_agent/approvals/parsing.py +0 -176
  193. package/src/team_agent/approvals/runtime_prompts.py +0 -171
  194. package/src/team_agent/approvals/status.py +0 -176
  195. package/src/team_agent/cli/__init__.py +0 -137
  196. package/src/team_agent/cli/commands.py +0 -481
  197. package/src/team_agent/cli/e2e.py +0 -202
  198. package/src/team_agent/cli/helpers.py +0 -226
  199. package/src/team_agent/cli/parser.py +0 -540
  200. package/src/team_agent/compiler.py +0 -334
  201. package/src/team_agent/coordinator/__init__.py +0 -53
  202. package/src/team_agent/coordinator/__main__.py +0 -119
  203. package/src/team_agent/coordinator/lifecycle.py +0 -411
  204. package/src/team_agent/coordinator/metadata.py +0 -61
  205. package/src/team_agent/coordinator/paths.py +0 -17
  206. package/src/team_agent/diagnose/__init__.py +0 -48
  207. package/src/team_agent/diagnose/checks.py +0 -101
  208. package/src/team_agent/diagnose/comms.py +0 -213
  209. package/src/team_agent/diagnose/health.py +0 -241
  210. package/src/team_agent/diagnose/orphan_cleanup.py +0 -364
  211. package/src/team_agent/diagnose/preflight.py +0 -194
  212. package/src/team_agent/diagnose/quick_start.py +0 -324
  213. package/src/team_agent/display/__init__.py +0 -92
  214. package/src/team_agent/display/adaptive.py +0 -511
  215. package/src/team_agent/display/backend.py +0 -46
  216. package/src/team_agent/display/close.py +0 -154
  217. package/src/team_agent/display/ghostty.py +0 -77
  218. package/src/team_agent/display/rebuild.py +0 -102
  219. package/src/team_agent/display/tiling.py +0 -156
  220. package/src/team_agent/display/worker_window.py +0 -114
  221. package/src/team_agent/display/workspace.py +0 -382
  222. package/src/team_agent/errors.py +0 -10
  223. package/src/team_agent/events.py +0 -84
  224. package/src/team_agent/fake_worker.py +0 -80
  225. package/src/team_agent/idle_predicate.py +0 -218
  226. package/src/team_agent/idle_takeover.py +0 -59
  227. package/src/team_agent/idle_takeover_wiring.py +0 -114
  228. package/src/team_agent/launch/__init__.py +0 -41
  229. package/src/team_agent/launch/bootstrap.py +0 -85
  230. package/src/team_agent/launch/config.py +0 -106
  231. package/src/team_agent/launch/core.py +0 -301
  232. package/src/team_agent/launch/requirements.py +0 -57
  233. package/src/team_agent/leader/__init__.py +0 -926
  234. package/src/team_agent/leader_binding.py +0 -183
  235. package/src/team_agent/lifecycle/__init__.py +0 -5
  236. package/src/team_agent/lifecycle/agents.py +0 -278
  237. package/src/team_agent/lifecycle/operations.py +0 -411
  238. package/src/team_agent/lifecycle/paste_buffer_hygiene.py +0 -39
  239. package/src/team_agent/lifecycle/start.py +0 -363
  240. package/src/team_agent/mcp_server/__init__.py +0 -42
  241. package/src/team_agent/mcp_server/__main__.py +0 -7
  242. package/src/team_agent/mcp_server/contracts.py +0 -148
  243. package/src/team_agent/mcp_server/normalize.py +0 -257
  244. package/src/team_agent/mcp_server/server.py +0 -150
  245. package/src/team_agent/mcp_server/tools.py +0 -352
  246. package/src/team_agent/message_store/__init__.py +0 -23
  247. package/src/team_agent/message_store/agent_health.py +0 -113
  248. package/src/team_agent/message_store/core.py +0 -497
  249. package/src/team_agent/message_store/leader_notification_log.py +0 -198
  250. package/src/team_agent/message_store/result_watchers.py +0 -251
  251. package/src/team_agent/message_store/schema.py +0 -308
  252. package/src/team_agent/message_store/schema_migration.py +0 -448
  253. package/src/team_agent/messaging/__init__.py +0 -1
  254. package/src/team_agent/messaging/activity_detector.py +0 -262
  255. package/src/team_agent/messaging/delivery.py +0 -504
  256. package/src/team_agent/messaging/deps.py +0 -247
  257. package/src/team_agent/messaging/idle_alerts.py +0 -423
  258. package/src/team_agent/messaging/internal_delivery.py +0 -46
  259. package/src/team_agent/messaging/leader.py +0 -497
  260. package/src/team_agent/messaging/leader_api_errors.py +0 -216
  261. package/src/team_agent/messaging/leader_panes.py +0 -673
  262. package/src/team_agent/messaging/owner_bypass.py +0 -29
  263. package/src/team_agent/messaging/result_delivery.py +0 -539
  264. package/src/team_agent/messaging/results.py +0 -447
  265. package/src/team_agent/messaging/scheduler.py +0 -450
  266. package/src/team_agent/messaging/send.py +0 -532
  267. package/src/team_agent/messaging/session_drift.py +0 -94
  268. package/src/team_agent/messaging/tmux_io.py +0 -506
  269. package/src/team_agent/messaging/tmux_prompt.py +0 -338
  270. package/src/team_agent/messaging/trust_auto_answer.py +0 -52
  271. package/src/team_agent/orchestrator/__init__.py +0 -376
  272. package/src/team_agent/orchestrator/plan.py +0 -122
  273. package/src/team_agent/orchestrator/state.py +0 -128
  274. package/src/team_agent/paths.py +0 -45
  275. package/src/team_agent/permissions.py +0 -123
  276. package/src/team_agent/profiles/__init__.py +0 -82
  277. package/src/team_agent/profiles/constants.py +0 -19
  278. package/src/team_agent/profiles/core.py +0 -407
  279. package/src/team_agent/profiles/helpers.py +0 -69
  280. package/src/team_agent/profiles/provider_env.py +0 -188
  281. package/src/team_agent/profiles/smoke.py +0 -201
  282. package/src/team_agent/provider_cli/__init__.py +0 -43
  283. package/src/team_agent/provider_cli/adapter.py +0 -172
  284. package/src/team_agent/provider_cli/base.py +0 -48
  285. package/src/team_agent/provider_cli/claude.py +0 -503
  286. package/src/team_agent/provider_cli/codex.py +0 -336
  287. package/src/team_agent/provider_cli/copilot.py +0 -8
  288. package/src/team_agent/provider_cli/fake.py +0 -39
  289. package/src/team_agent/provider_cli/gemini.py +0 -95
  290. package/src/team_agent/provider_cli/opencode.py +0 -8
  291. package/src/team_agent/provider_cli/prompt.py +0 -62
  292. package/src/team_agent/provider_cli/registry.py +0 -18
  293. package/src/team_agent/provider_cli/unsupported.py +0 -32
  294. package/src/team_agent/provider_state/README.md +0 -78
  295. package/src/team_agent/provider_state/__init__.py +0 -91
  296. package/src/team_agent/provider_state/claude.py +0 -86
  297. package/src/team_agent/provider_state/codex.py +0 -84
  298. package/src/team_agent/provider_state/common.py +0 -207
  299. package/src/team_agent/provider_state/registry.py +0 -118
  300. package/src/team_agent/providers.py +0 -163
  301. package/src/team_agent/quality_gates.py +0 -104
  302. package/src/team_agent/restart/__init__.py +0 -34
  303. package/src/team_agent/restart/orchestration.py +0 -554
  304. package/src/team_agent/restart/selection.py +0 -89
  305. package/src/team_agent/restart/snapshot.py +0 -70
  306. package/src/team_agent/routing.py +0 -84
  307. package/src/team_agent/runtime.py +0 -1243
  308. package/src/team_agent/rust_core.py +0 -327
  309. package/src/team_agent/sessions/__init__.py +0 -25
  310. package/src/team_agent/sessions/capture.py +0 -144
  311. package/src/team_agent/sessions/inventory.py +0 -44
  312. package/src/team_agent/sessions/resume.py +0 -135
  313. package/src/team_agent/simple_yaml.py +0 -236
  314. package/src/team_agent/spec.py +0 -370
  315. package/src/team_agent/state.py +0 -693
  316. package/src/team_agent/status/__init__.py +0 -63
  317. package/src/team_agent/status/approvals.py +0 -52
  318. package/src/team_agent/status/compact.py +0 -158
  319. package/src/team_agent/status/constants.py +0 -18
  320. package/src/team_agent/status/inbox.py +0 -58
  321. package/src/team_agent/status/peek.py +0 -117
  322. package/src/team_agent/status/queries.py +0 -199
  323. package/src/team_agent/task_graph.py +0 -80
  324. package/src/team_agent/terminal.py +0 -57
  325. package/src/team_agent/wake.py +0 -58
  326. package/src/team_agent/watch/__init__.py +0 -145
@@ -0,0 +1,423 @@
1
+ //! codex startup-prompt recognizer — workspace-trust + update-skip screen detection.
2
+ //!
3
+ //! Golden (READ-ONLY truth `team-agent-public` v0.2.11): `provider_cli/codex.py`
4
+ //! - `CodexAdapter.handle_startup_prompts` (:142-182)
5
+ //! - `maybe_skip_update_prompt` (:262-268)
6
+ //!
7
+ //! recognizer-class (Gap 29 — burned 4 Mac minis): a NAIVE substring port gets the RECENCY命门 wrong.
8
+ //! A prompt is acted on ONLY when its `rfind` position is GREATER than the ready marker's `rfind`
9
+ //! position (i.e. it appears LATER / more recently in the captured scrollback). A stale prompt ABOVE an
10
+ //! already-ready marker is left alone — ready wins and polling stops. RED-first skeleton; porter-d
11
+ //! implements GREEN black-box against golden codex.py.
12
+
13
+ use std::time::Duration;
14
+
15
+ use crate::transport::{CaptureRange, Key, Target, Transport};
16
+
17
+ const TRUST_MARKERS: &[&str] = &[
18
+ "Do you trust the contents of this directory?",
19
+ "Do you trust the files in this folder?",
20
+ "Do you trust this folder?",
21
+ ];
22
+ const UPDATE_MARKERS: &[&str] = &["Update available!", "Update now"];
23
+ /// Plain ready markers (not the bare `›` glyph — that glyph also indicates a
24
+ /// numbered-menu selector and is handled by [`rightmost_input_prompt_glyph`] with
25
+ /// shape gating per N15 / CR-063: detect by SHAPE, not a single Unicode codepoint).
26
+ const READY_MARKERS: &[&str] = &["OpenAI Codex", "codex>"];
27
+
28
+ /// Per-poll decision for the codex startup screen. Golden order each iteration (codex.py:160-181):
29
+ /// update-skip is checked FIRST, then workspace-trust, then ready (stop), else keep polling.
30
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
31
+ pub enum StartupScreenDecision {
32
+ /// `maybe_skip_update_prompt` matched: update_pos >= 0 && update_pos > ready_pos (codex.py:262-267).
33
+ SkipUpdatePrompt,
34
+ /// workspace-trust: trust_pos >= 0 && trust_pos > ready_pos (codex.py:166-174).
35
+ AnswerWorkspaceTrust,
36
+ /// ready_pos >= 0 with no actionable prompt above it (codex.py:178) -> stop polling.
37
+ Ready,
38
+ /// none of the above (codex.py:180) -> sleep + keep polling.
39
+ KeepPolling,
40
+ }
41
+
42
+ /// One handled startup prompt — an entry of golden's `handled` list.
43
+ #[derive(Debug, Clone, PartialEq, Eq)]
44
+ pub struct HandledPrompt {
45
+ pub prompt: String,
46
+ pub action: String,
47
+ }
48
+
49
+ /// PURE recognizer (codex.py:160-181 + maybe_skip_update_prompt :262-268): captured scrollback ->
50
+ /// decision. NO IO. The RECENCY命门: a prompt is acted on ONLY when its `rfind` position is strictly
51
+ /// GREATER than the ready marker's `rfind` position. update is evaluated before trust.
52
+ /// trust strings (rfind max of): "Do you trust the contents of this directory?" /
53
+ /// "Do you trust the files in this folder?" / "Do you trust this folder?"
54
+ /// update strings (rfind max of): "Update available!" / "Update now"
55
+ /// ready markers (rfind max of): "OpenAI Codex" / "›" / "codex>"
56
+ pub fn classify_codex_startup_screen(output: &str) -> StartupScreenDecision {
57
+ // CR-063 / subroot real-machine residual: actionable-shape override BEFORE recency.
58
+ // The recency model ("prompt above ready = stale-scrolled, ignore") assumes the
59
+ // active state is the LATEST byte on screen. Real Codex breaks that assumption:
60
+ // while a trust modal is still active, Codex pre-renders the Update box, the
61
+ // OpenAI Codex banner, AND a bottom `› Find and fix a bug…` input-prompt indicator
62
+ // BELOW the trust menu — so recency would mark the screen Ready and the trust
63
+ // menu would never be answered. When the captured text has the actionable trust
64
+ // shape (`Do you trust …` phrase + a `› <digit>. ` numbered-menu line, N15),
65
+ // the modal IS the live state regardless of what comes after it. Return early.
66
+ if has_actionable_trust_shape(output) {
67
+ return StartupScreenDecision::AnswerWorkspaceTrust;
68
+ }
69
+ // N15/CR-063 root-cause (recency lane): the bare `›` glyph is BOTH the Codex
70
+ // input-prompt indicator AND the numbered-menu selector on a real trust pane
71
+ // (`› 1. Yes, continue`). Detect by SHAPE: `›` is a ready marker only when its
72
+ // tail is NOT a `<digit>. ` menu item.
73
+ let ready_pos = max_two(
74
+ max_rfind(output, READY_MARKERS),
75
+ rightmost_input_prompt_glyph(output),
76
+ );
77
+ if is_more_recent(max_rfind(output, UPDATE_MARKERS), ready_pos) {
78
+ return StartupScreenDecision::SkipUpdatePrompt;
79
+ }
80
+ if is_more_recent(max_rfind(output, TRUST_MARKERS), ready_pos) {
81
+ return StartupScreenDecision::AnswerWorkspaceTrust;
82
+ }
83
+ if ready_pos.is_some() {
84
+ StartupScreenDecision::Ready
85
+ } else {
86
+ StartupScreenDecision::KeepPolling
87
+ }
88
+ }
89
+
90
+ /// Actionable trust shape (N15): the captured text contains a trust phrase AND a
91
+ /// numbered-menu selector line `› <digit>. `. This is the modal-still-active signal
92
+ /// that survives Codex's pre-rendering of the banner/input prompt below the menu.
93
+ /// Does NOT match a single-screen "trust phrase + bare `›`" (e.g. plain Ready
94
+ /// follow-up text), so historical "trust ABOVE ready" recency tests keep passing
95
+ /// — those fixtures do not include a `› <digit>. ` menu line.
96
+ fn has_actionable_trust_shape(output: &str) -> bool {
97
+ if !TRUST_MARKERS.iter().any(|marker| output.contains(marker)) {
98
+ return false;
99
+ }
100
+ contains_numbered_menu_glyph(output)
101
+ }
102
+
103
+ /// `true` iff any `›` in the output is followed by a numbered-menu selector
104
+ /// (` <digit>. `). The shape pairs the glyph with a digit-dot line item — the
105
+ /// Codex trust/update menu printing convention.
106
+ fn contains_numbered_menu_glyph(output: &str) -> bool {
107
+ let glyph = '›';
108
+ let glyph_len = glyph.len_utf8();
109
+ let mut start = 0;
110
+ while let Some(rel) = output[start..].find(glyph) {
111
+ let abs = start + rel;
112
+ let tail_start = abs + glyph_len;
113
+ if tail_start > output.len() {
114
+ break;
115
+ }
116
+ if is_numbered_menu_tail(&output[tail_start..]) {
117
+ return true;
118
+ }
119
+ start = tail_start;
120
+ }
121
+ false
122
+ }
123
+
124
+ /// Rightmost `›` whose tail is NOT a numbered-menu selector (` <digit>. `). A bare
125
+ /// `›` followed by free text or whitespace is the Codex main-input prompt indicator;
126
+ /// a `›` followed by `1. Yes, continue` is part of the trust/update menu and is NOT
127
+ /// a ready signal.
128
+ fn rightmost_input_prompt_glyph(output: &str) -> Option<usize> {
129
+ let glyph = '›';
130
+ let glyph_len = glyph.len_utf8();
131
+ let mut best = None;
132
+ let bytes = output.as_bytes();
133
+ let mut start = 0;
134
+ while let Some(rel) = output[start..].find(glyph) {
135
+ let abs = start + rel;
136
+ let tail_start = abs + glyph_len;
137
+ if tail_start <= bytes.len() && !is_numbered_menu_tail(&output[tail_start..]) {
138
+ best = Some(abs);
139
+ }
140
+ start = tail_start;
141
+ if start > output.len() {
142
+ break;
143
+ }
144
+ }
145
+ best
146
+ }
147
+
148
+ fn is_numbered_menu_tail(tail: &str) -> bool {
149
+ let trimmed = tail.trim_start_matches(' ');
150
+ let mut chars = trimmed.chars();
151
+ matches!(
152
+ (chars.next(), chars.next()),
153
+ (Some(d), Some('.')) if d.is_ascii_digit()
154
+ )
155
+ }
156
+
157
+ fn max_two(a: Option<usize>, b: Option<usize>) -> Option<usize> {
158
+ match (a, b) {
159
+ (Some(x), Some(y)) => Some(x.max(y)),
160
+ (Some(x), None) | (None, Some(x)) => Some(x),
161
+ (None, None) => None,
162
+ }
163
+ }
164
+
165
+ /// Capture-poll loop (codex.py:142-182) over the `transport.capture()` seam (NOT a raw subprocess, so
166
+ /// it stays unit-testable). On `AnswerWorkspaceTrust` -> send `Enter` + push
167
+ /// {prompt:"codex_workspace_trust", action:"sent_enter"}; on `SkipUpdatePrompt` -> send `Down`,`Enter`
168
+ /// + push {prompt:"codex_update_available", action:"sent_skip"}; on `Ready` -> stop. Loops up to
169
+ /// `checks` (golden default 30), `sleep_s` (golden 0.5) between iterations. Returns the ordered
170
+ /// `handled` list. Capture is full scrollback (golden `tmux capture-pane -p -S - -t <target>`).
171
+ pub fn codex_handle_startup_prompts(
172
+ transport: &dyn Transport,
173
+ target: &Target,
174
+ checks: usize,
175
+ sleep_s: f64,
176
+ ) -> Vec<HandledPrompt> {
177
+ let mut handled = Vec::new();
178
+ for _ in 0..checks {
179
+ let screen = match transport.capture(target, CaptureRange::Full) {
180
+ Ok(captured) => captured.text,
181
+ Err(_) => String::new(),
182
+ };
183
+ match classify_codex_startup_screen(&screen) {
184
+ StartupScreenDecision::SkipUpdatePrompt => {
185
+ let _ = transport.send_keys(target, &[Key::Down, Key::Enter]);
186
+ handled.push(HandledPrompt {
187
+ prompt: "codex_update_available".to_string(),
188
+ action: "sent_skip".to_string(),
189
+ });
190
+ sleep_between_polls(sleep_s);
191
+ }
192
+ StartupScreenDecision::AnswerWorkspaceTrust => {
193
+ let _ = transport.send_keys(target, &[Key::Enter]);
194
+ handled.push(HandledPrompt {
195
+ prompt: "codex_workspace_trust".to_string(),
196
+ action: "sent_enter".to_string(),
197
+ });
198
+ sleep_between_polls(sleep_s);
199
+ }
200
+ StartupScreenDecision::Ready => break,
201
+ StartupScreenDecision::KeepPolling => sleep_between_polls(sleep_s),
202
+ }
203
+ }
204
+ handled
205
+ }
206
+
207
+ fn max_rfind(output: &str, needles: &[&str]) -> Option<usize> {
208
+ needles.iter().filter_map(|needle| output.rfind(needle)).max()
209
+ }
210
+
211
+ fn is_more_recent(prompt_pos: Option<usize>, ready_pos: Option<usize>) -> bool {
212
+ match (prompt_pos, ready_pos) {
213
+ (Some(prompt), Some(ready)) => prompt > ready,
214
+ (Some(_), None) => true,
215
+ _ => false,
216
+ }
217
+ }
218
+
219
+ fn sleep_between_polls(sleep_s: f64) {
220
+ let millis = (sleep_s * 1000.0).round();
221
+ if millis.is_finite() && millis > 0.0 && millis <= u64::MAX as f64 {
222
+ std::thread::sleep(Duration::from_millis(millis as u64));
223
+ }
224
+ }
225
+
226
+ #[cfg(test)]
227
+ mod tests {
228
+ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
229
+ use super::*;
230
+ use crate::model::enums::PaneLiveness;
231
+ use crate::transport::{
232
+ AttachOutcome, BackendKind, CaptureRange, CapturedText, InjectPayload, InjectReport, Key,
233
+ PaneField, PaneId, PaneInfo, SessionName, SetEnvOutcome, SpawnResult, Target, TransportError,
234
+ WindowName,
235
+ };
236
+ use std::collections::BTreeMap;
237
+ use std::path::Path;
238
+ use std::sync::Mutex;
239
+
240
+ // ── EXACT golden strings (provider_cli/codex.py). Do not paraphrase — recognizer-class. ──────────
241
+ const TRUST_DIR: &str = "Do you trust the contents of this directory?";
242
+ const TRUST_FILES: &str = "Do you trust the files in this folder?";
243
+ const TRUST_FOLDER: &str = "Do you trust this folder?";
244
+ const UPDATE_AVAIL: &str = "Update available!";
245
+ const UPDATE_NOW: &str = "Update now";
246
+ const READY_BANNER: &str = "OpenAI Codex";
247
+ const READY_PROMPT: &str = "›"; // U+203A
248
+ const READY_BARE: &str = "codex>";
249
+
250
+ // ── ① + ② RED核心 — workspace-trust MORE RECENT than ready -> answer it ──────────────────────────
251
+ #[test]
252
+ fn trust_more_recent_than_ready_answers_workspace_trust() {
253
+ // ready banner appears early; the trust prompt appears LATER; no ready marker after it
254
+ // => trust_pos > ready_pos => answer.
255
+ let screen = format!("{READY_BANNER} v1.2\nwelcome\n\n{TRUST_DIR}\n hit enter ");
256
+ assert_eq!(
257
+ classify_codex_startup_screen(&screen),
258
+ StartupScreenDecision::AnswerWorkspaceTrust
259
+ );
260
+ }
261
+
262
+ // ── ② 命门 CORE — a STALE trust prompt ABOVE the ready marker is NOT answered (ready wins) ────────
263
+ #[test]
264
+ fn stale_trust_above_ready_is_not_answered_ready_wins() {
265
+ // trust prompt FIRST, then a ready marker LATER => trust_pos < ready_pos => do NOT answer.
266
+ // This is the positional-recency命门 a naive substring port gets wrong (would re-send Enter).
267
+ let screen = format!("{TRUST_DIR}\n[trusted earlier]\n{READY_BANNER} ready\n{READY_PROMPT} ");
268
+ assert_eq!(
269
+ classify_codex_startup_screen(&screen),
270
+ StartupScreenDecision::Ready,
271
+ "RECENCY命门: a trust prompt ABOVE the ready marker is stale; ready wins, NO Enter sent"
272
+ );
273
+ }
274
+
275
+ #[test]
276
+ fn each_trust_string_recognized_when_more_recent() {
277
+ for s in [TRUST_DIR, TRUST_FILES, TRUST_FOLDER] {
278
+ let screen = format!("{READY_BANNER}\n...banner...\n{s}\n");
279
+ assert_eq!(
280
+ classify_codex_startup_screen(&screen),
281
+ StartupScreenDecision::AnswerWorkspaceTrust,
282
+ "trust string {s:?} after ready must answer"
283
+ );
284
+ }
285
+ }
286
+
287
+ // ── ③ sibling — update-skip recognizer (maybe_skip_update_prompt), same recency命门 ──────────────
288
+ #[test]
289
+ fn update_more_recent_than_ready_skips_update() {
290
+ for s in [UPDATE_AVAIL, UPDATE_NOW] {
291
+ let screen = format!("{READY_BANNER}\nblah\n{s}\n");
292
+ assert_eq!(
293
+ classify_codex_startup_screen(&screen),
294
+ StartupScreenDecision::SkipUpdatePrompt,
295
+ "update string {s:?} after ready must skip"
296
+ );
297
+ }
298
+ }
299
+
300
+ #[test]
301
+ fn stale_update_above_ready_is_not_skipped_ready_wins() {
302
+ let screen = format!("{UPDATE_AVAIL}\n{READY_BANNER}\n{READY_PROMPT} ");
303
+ assert_eq!(classify_codex_startup_screen(&screen), StartupScreenDecision::Ready);
304
+ }
305
+
306
+ // ── golden ORDER — update is checked BEFORE trust (both more recent) -> SkipUpdatePrompt wins ─────
307
+ #[test]
308
+ fn update_checked_before_trust() {
309
+ // both update + trust appear after the ready marker; golden runs maybe_skip_update_prompt
310
+ // FIRST each iteration -> the screen resolves to SkipUpdatePrompt, not AnswerWorkspaceTrust.
311
+ let screen = format!("{READY_BANNER}\n{TRUST_DIR}\n{UPDATE_AVAIL}\n");
312
+ assert_eq!(classify_codex_startup_screen(&screen), StartupScreenDecision::SkipUpdatePrompt);
313
+ }
314
+
315
+ // ── ready-only / neither ─────────────────────────────────────────────────────────────────────────
316
+ #[test]
317
+ fn each_ready_marker_alone_is_ready() {
318
+ for m in [READY_BANNER, READY_PROMPT, READY_BARE] {
319
+ let screen = format!("booting...\n{m} ");
320
+ assert_eq!(
321
+ classify_codex_startup_screen(&screen),
322
+ StartupScreenDecision::Ready,
323
+ "ready marker {m:?} alone must be Ready"
324
+ );
325
+ }
326
+ }
327
+
328
+ #[test]
329
+ fn no_prompt_no_ready_keeps_polling() {
330
+ assert_eq!(
331
+ classify_codex_startup_screen("loading spinner...\nstill starting\n"),
332
+ StartupScreenDecision::KeepPolling
333
+ );
334
+ }
335
+
336
+ // ── ④ transport.capture() SEAM — the loop answers trust then breaks on ready, via the seam ───────
337
+ /// Scripted transport: `capture` pops the next canned screen; `send_keys` records the keys. All
338
+ /// other methods are unreachable by the startup-prompt loop.
339
+ struct ScriptedTransport {
340
+ screens: Mutex<Vec<String>>,
341
+ sent: Mutex<Vec<Vec<Key>>>,
342
+ }
343
+ impl Transport for ScriptedTransport {
344
+ fn kind(&self) -> BackendKind {
345
+ BackendKind::Tmux
346
+ }
347
+ fn spawn_first(&self, _s: &SessionName, _w: &WindowName, _a: &[String], _c: &Path, _e: &BTreeMap<String, String>) -> Result<SpawnResult, TransportError> {
348
+ unimplemented!("not reached by startup-prompt loop")
349
+ }
350
+ fn spawn_into(&self, _s: &SessionName, _w: &WindowName, _a: &[String], _c: &Path, _e: &BTreeMap<String, String>) -> Result<SpawnResult, TransportError> {
351
+ unimplemented!("not reached by startup-prompt loop")
352
+ }
353
+ fn inject(&self, _t: &Target, _p: &InjectPayload, _submit: Key, _b: bool) -> Result<InjectReport, TransportError> {
354
+ unimplemented!("not reached")
355
+ }
356
+ fn send_keys(&self, _t: &Target, keys: &[Key]) -> Result<(), TransportError> {
357
+ self.sent.lock().unwrap().push(keys.to_vec());
358
+ Ok(())
359
+ }
360
+ fn capture(&self, _t: &Target, range: CaptureRange) -> Result<CapturedText, TransportError> {
361
+ let mut q = self.screens.lock().unwrap();
362
+ let text = if q.is_empty() { String::new() } else { q.remove(0) };
363
+ Ok(CapturedText { text, range })
364
+ }
365
+ fn query(&self, _t: &Target, _f: PaneField) -> Result<Option<String>, TransportError> {
366
+ Ok(None)
367
+ }
368
+ fn liveness(&self, _p: &PaneId) -> Result<PaneLiveness, TransportError> {
369
+ unimplemented!("not reached")
370
+ }
371
+ fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
372
+ unimplemented!("not reached")
373
+ }
374
+ fn has_session(&self, _s: &SessionName) -> Result<bool, TransportError> {
375
+ Ok(true)
376
+ }
377
+ fn list_windows(&self, _s: &SessionName) -> Result<Vec<WindowName>, TransportError> {
378
+ unimplemented!("not reached")
379
+ }
380
+ fn set_session_env(&self, _s: &SessionName, _k: &str, _v: &str) -> Result<SetEnvOutcome, TransportError> {
381
+ unimplemented!("not reached")
382
+ }
383
+ fn kill_session(&self, _s: &SessionName) -> Result<(), TransportError> {
384
+ unimplemented!("not reached")
385
+ }
386
+ fn kill_window(&self, _t: &Target) -> Result<(), TransportError> {
387
+ unimplemented!("not reached")
388
+ }
389
+ fn attach_session(&self, _s: &SessionName) -> Result<AttachOutcome, TransportError> {
390
+ unimplemented!("not reached")
391
+ }
392
+ }
393
+
394
+ #[test]
395
+ fn loop_answers_trust_then_breaks_on_ready_via_capture_seam() {
396
+ let t = ScriptedTransport {
397
+ screens: Mutex::new(vec![
398
+ // iter 1: trust prompt more recent than ready -> answer (send Enter) + continue.
399
+ format!("{READY_BANNER}\n{TRUST_DIR}\n"),
400
+ // iter 2: ready marker, no actionable prompt above it -> break.
401
+ format!("{READY_BANNER} ready\n{READY_PROMPT} "),
402
+ ]),
403
+ sent: Mutex::new(Vec::new()),
404
+ };
405
+ let target = Target::Pane(PaneId::new("%1"));
406
+
407
+ let handled = codex_handle_startup_prompts(&t, &target, 5, 0.0);
408
+
409
+ assert_eq!(
410
+ handled,
411
+ vec![HandledPrompt {
412
+ prompt: "codex_workspace_trust".to_string(),
413
+ action: "sent_enter".to_string(),
414
+ }],
415
+ "the loop must answer the workspace-trust prompt exactly once, then break on ready"
416
+ );
417
+ let sent = t.sent.lock().unwrap();
418
+ assert!(
419
+ sent.iter().any(|keys| keys.as_slice() == [Key::Enter]),
420
+ "on workspace-trust the loop must send Enter via the transport.capture() seam; got {sent:?}"
421
+ );
422
+ }
423
+ }
@@ -0,0 +1,239 @@
1
+ #[test]
2
+ fn abnormal_dedup_key_uses_signature_and_optional_turn_id() {
3
+ // probe(/tmp/probe_idle.py read_fault_facts): the C8 dedup key is
4
+ // (Signature, Option<TurnId>). Golden facts below are the exact extraction.
5
+
6
+ // claude api_error → signature=api_error, turn_id=sess-9 (sessionId), kind=error.
7
+ let api_err = [serde_json::json!(
8
+ {"type":"system","subtype":"api_error","level":"error","sessionId":"sess-9"}
9
+ )];
10
+ let f = read_fault_facts(&api_err, Provider::Claude);
11
+ assert_eq!(f.len(), 1);
12
+ assert_eq!(f[0].signature, Signature::new("api_error"));
13
+ assert_eq!(f[0].turn_id, Some(TurnId::new("sess-9")));
14
+ assert_eq!(f[0].kind, FactKind::Error);
15
+
16
+ // claude tool_result is_error → signature=tool_result_is_error,
17
+ // turn_id=parentUuid ("p-1"), kind=error.
18
+ let tool_err = [serde_json::json!(
19
+ {"type":"user","uuid":"u","parentUuid":"p-1",
20
+ "message":{"content":[{"type":"tool_result","is_error":true}]}}
21
+ )];
22
+ let f = read_fault_facts(&tool_err, Provider::Claude);
23
+ assert_eq!(f.len(), 1);
24
+ assert_eq!(f[0].signature, Signature::new("tool_result_is_error"));
25
+ assert_eq!(f[0].turn_id, Some(TurnId::new("p-1")));
26
+
27
+ // claude api_error with NO ids → key = (api_error, None) — Option must hold None.
28
+ let api_err_noids = [serde_json::json!(
29
+ {"type":"system","subtype":"api_error","level":"error"}
30
+ )];
31
+ let f = read_fault_facts(&api_err_noids, Provider::Claude);
32
+ assert_eq!(f.len(), 1);
33
+ assert_eq!(f[0].signature, Signature::new("api_error"));
34
+ assert_eq!(f[0].turn_id, None, "api_error with no ids dedups on (api_error, None)");
35
+
36
+ // codex turn_failed → signature=turn_failed, turn_id=ct4, kind=failed.
37
+ let codex_failed = [serde_json::json!(
38
+ {"jsonrpc":"2.0","method":"turn/completed","params":{"turn":{"id":"ct4","status":"failed"}}}
39
+ )];
40
+ let f = read_fault_facts(&codex_failed, Provider::Codex);
41
+ assert_eq!(f.len(), 1);
42
+ assert_eq!(f[0].signature, Signature::new("turn_failed"));
43
+ assert_eq!(f[0].turn_id, Some(TurnId::new("ct4")));
44
+ assert_eq!(f[0].kind, FactKind::Failed);
45
+
46
+ // codex approval → signature=approval_required, turn_id=ct5, kind=approval.
47
+ let codex_approval = [serde_json::json!(
48
+ {"jsonrpc":"2.0","method":"session/requestApproval","params":{"turnId":"ct5"}}
49
+ )];
50
+ let f = read_fault_facts(&codex_approval, Provider::Codex);
51
+ assert_eq!(f.len(), 1);
52
+ assert_eq!(f[0].signature, Signature::new("approval_required"));
53
+ assert_eq!(f[0].turn_id, Some(TurnId::new("ct5")));
54
+ assert_eq!(f[0].kind, FactKind::Approval);
55
+ }
56
+
57
+ // ---- (e) session capture payload + bug-085 fallback (confidence=low) ----
58
+
59
+ #[test]
60
+ fn capture_session_id_success_payload_fields() {
61
+ // claude.py:73-106 success → CapturedSession high confidence, fs_watch,
62
+ // session_id Some, rollout_path Some. Drive the real fn against a temp cwd
63
+ // that contains a discoverable transcript; assert ALL 5 typed fields.
64
+ let dir = std::env::temp_dir().join(format!(
65
+ "ta-cap-success-{}",
66
+ std::process::id()
67
+ ));
68
+ let _ = std::fs::create_dir_all(&dir);
69
+ let transcript = dir.join("session.jsonl");
70
+ let _ = std::fs::write(
71
+ &transcript,
72
+ r#"{"type":"user","sessionId":"sess-1","cwd":"PLACEHOLDER","message":{"content":"hi"}}"#,
73
+ );
74
+ let adapter = get_adapter(Provider::ClaudeCode);
75
+ let cs = adapter
76
+ .capture_session_id("agentX", &dir, 3)
77
+ .expect("capture ok")
78
+ .expect("transcript found → Some");
79
+ assert_eq!(cs.captured_via, CaptureVia::FsWatch);
80
+ assert_eq!(cs.attribution_confidence, Confidence::High);
81
+ assert!(cs.session_id.is_some(), "found transcript yields a session_id");
82
+ assert!(cs.rollout_path.is_some(), "found transcript yields a rollout_path");
83
+ let _ = std::fs::remove_dir_all(&dir);
84
+ }
85
+
86
+ #[test]
87
+ fn capture_session_id_bug085_compatible_api_fallback_is_low_confidence_none_session() {
88
+ // probe(claude fallback): compatible_api transcript w/o a session_id →
89
+ // session_id=None, captured_via=fs_mtime_fallback, confidence=low,
90
+ // rollout_path SET. The half-state is LEGAL and must not panic (bug-085).
91
+ let dir = std::env::temp_dir().join(format!(
92
+ "ta-cap-fallback-{}",
93
+ std::process::id()
94
+ ));
95
+ let _ = std::fs::create_dir_all(&dir);
96
+ let transcript = dir.join("nosession.jsonl");
97
+ let _ = std::fs::write(&transcript, r#"{"type":"user","message":{"content":"hi"}}"#);
98
+ let adapter = get_adapter(Provider::ClaudeCode);
99
+ let cs = adapter
100
+ .capture_session_id("agentX", &dir, 3)
101
+ .expect("capture ok")
102
+ .expect("fallback transcript → Some half-state");
103
+ assert_eq!(cs.session_id, None, "bug-085: fallback session_id is None");
104
+ assert_eq!(cs.captured_via, CaptureVia::FsMtimeFallback);
105
+ assert_eq!(cs.attribution_confidence, Confidence::Low);
106
+ assert!(cs.rollout_path.is_some(), "fallback still pins rollout_path");
107
+ let _ = std::fs::remove_dir_all(&dir);
108
+ }
109
+
110
+ #[test]
111
+ fn capture_session_id_no_match_returns_none() {
112
+ // claude.py:111-113 deadline with no match → Ok(None), explicitly NOT Err.
113
+ let empty = std::env::temp_dir().join(format!("ta-cap-empty-{}", std::process::id()));
114
+ let _ = std::fs::create_dir_all(&empty);
115
+ let adapter = get_adapter(Provider::ClaudeCode);
116
+ let res = adapter.capture_session_id("agentX", &empty, 0);
117
+ // ProviderError 非 PartialEq(携 Io)→ 用 matches! 而非 ==。
118
+ assert!(matches!(res, Ok(None)), "no transcript + timeout 0 → Ok(None), never Err");
119
+ let _ = std::fs::remove_dir_all(&empty);
120
+ }
121
+
122
+ // ---- resume / fork / build_command / caps — command-build core ----
123
+
124
+ #[test]
125
+ fn claude_caps_resume_and_fork_and_native_mcp() {
126
+ // doc §59 + claude.py: claude supports resume + fork (static cap true,
127
+ // runtime auth-gated) + native --mcp-config; does NOT write a global settings file.
128
+ let adapter = get_adapter(Provider::ClaudeCode);
129
+ let caps = adapter.caps();
130
+ assert_eq!(
131
+ caps,
132
+ ProviderCaps {
133
+ resume: true,
134
+ fork: true,
135
+ native_mcp_config: true,
136
+ writes_global_settings: false,
137
+ }
138
+ );
139
+ }
140
+
141
+ #[test]
142
+ fn gemini_writes_global_settings_cap() {
143
+ // gemini.py:40-78 — install_mcp writes ~/.gemini/settings.json →
144
+ // caps.writes_global_settings=true, native_mcp_config=false.
145
+ let adapter = get_adapter(Provider::GeminiCli);
146
+ let caps = adapter.caps();
147
+ assert!(caps.writes_global_settings, "gemini writes a global settings file");
148
+ assert!(!caps.native_mcp_config, "gemini has no native --mcp-config flag");
149
+ }
150
+
151
+ #[test]
152
+ fn claude_fork_blocked_under_compatible_api() {
153
+ // claude.py:54 supports_session_fork == (auth_mode != compatible_api).
154
+ // fork under CompatibleApi must Err (never silently empty) — capability path.
155
+ let adapter = get_adapter(Provider::ClaudeCode);
156
+ let sid = SessionId::new("src-1");
157
+ let res = adapter.fork(Some(&sid), AuthMode::CompatibleApi, None);
158
+ assert!(
159
+ matches!(
160
+ res,
161
+ Err(ProviderError::CapabilityUnsupported(_)) | Err(ProviderError::ResumeUnavailable(_))
162
+ ),
163
+ "fork under compatible_api must Err, got {res:?}"
164
+ );
165
+ }
166
+
167
+ #[test]
168
+ fn build_resume_command_without_session_id_is_resume_unavailable() {
169
+ // claude.py:41-42 — no session_id → ResumeUnavailable, never a bare crash.
170
+ let adapter = get_adapter(Provider::ClaudeCode);
171
+ let res = adapter.build_resume_command(None, AuthMode::Subscription, None);
172
+ assert!(
173
+ matches!(res, Err(ProviderError::ResumeUnavailable(_))),
174
+ "resume without session_id must be ResumeUnavailable, got {res:?}"
175
+ );
176
+ }
177
+
178
+ #[test]
179
+ fn session_is_resumable_none_session_is_false_not_panic() {
180
+ // claude.py:116-118 — session_id falsy → not resumable; bug-085 None穿透.
181
+ let adapter = get_adapter(Provider::ClaudeCode);
182
+ let res = adapter.session_is_resumable(None, AuthMode::CompatibleApi);
183
+ assert!(matches!(res, Ok(false)), "None session → Ok(false), never panic (bug-085)");
184
+ }
185
+
186
+ /// true iff `needle` (e.g. ["--model","opus"]) appears as a contiguous run in `hay`.
187
+ fn argv_contains_adjacent(hay: &[String], needle: &[&str]) -> bool {
188
+ if needle.is_empty() {
189
+ return true;
190
+ }
191
+ hay.windows(needle.len())
192
+ .any(|w| w.iter().zip(needle).all(|(a, b)| a == b))
193
+ }
194
+
195
+ #[test]
196
+ fn build_command_includes_model_and_system_prompt() {
197
+ // claude.py:175-199 — _base_command appends --model <m> + --append-system-prompt <p>;
198
+ // --strict-mcp-config only appears when mcp_config is present. With mcp_config=None,
199
+ // assert both flag pairs present adjacently AND --strict-mcp-config ABSENT.
200
+ let adapter = get_adapter(Provider::ClaudeCode);
201
+ let argv = adapter
202
+ .build_command(AuthMode::Subscription, None, Some("be helpful"), Some("opus"))
203
+ .expect("build_command ok");
204
+ assert!(
205
+ argv_contains_adjacent(&argv, &["--model", "opus"]),
206
+ "argv must contain `--model opus` adjacency: {argv:?}"
207
+ );
208
+ assert!(
209
+ argv_contains_adjacent(&argv, &["--append-system-prompt", "be helpful"]),
210
+ "argv must contain `--append-system-prompt 'be helpful'`: {argv:?}"
211
+ );
212
+ assert!(
213
+ !argv.iter().any(|a| a == "--strict-mcp-config"),
214
+ "no mcp_config → --strict-mcp-config must be absent: {argv:?}"
215
+ );
216
+ }
217
+
218
+ #[test]
219
+ fn unsupported_provider_capability_error_message_shape() {
220
+ // unsupported.py:31 ProviderCapabilityError — placeholder providers reject
221
+ // on call. The skeleton Provider enum has no Copilot/Opencode variant, so
222
+ // the unsupported path is exercised through ProviderError::CapabilityUnsupported
223
+ // construction (message contract) here; full plug dispatch deferred.
224
+ let e = ProviderError::CapabilityUnsupported("opencode:start".to_string());
225
+ assert_eq!(
226
+ e.to_string(),
227
+ "provider capability unsupported: opencode:start"
228
+ );
229
+ let e2 = ProviderError::ResumeUnavailable("claude resume requires session_id".to_string());
230
+ assert_eq!(
231
+ e2.to_string(),
232
+ "resume unavailable: claude resume requires session_id"
233
+ );
234
+ }
235
+
236
+ // ═══════════════ P2 FIX-LOOP RED (复绿即对抗 cross-model findings) ═══════════════
237
+ // Lock the CORRECT Python v0.2.11 behavior the strengthened contracts missed.
238
+ // Golden re-probed via /tmp/probe_p2_provider.py vs team-agent-public @ 439bef8.
239
+