@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,2109 @@
1
+ //! lifecycle::launch —— 冷启 / quick-start / 危险审批探测 + add/fork / plan 起步与推进。
2
+
3
+ use std::collections::{BTreeMap, BTreeSet};
4
+ use std::path::{Path, PathBuf};
5
+ use std::process::Command;
6
+
7
+ use crate::model::enums::{AuthMode, DisplayBackend, PaneLiveness, Provider};
8
+ use crate::model::ids::AgentId;
9
+ use crate::model::permissions::{self, AgentPermissionInput};
10
+ use crate::model::yaml::{self, Value};
11
+ use crate::state::persist::{load_runtime_state, save_runtime_state};
12
+ use crate::transport::{SessionName, Target, Transport, WindowName};
13
+
14
+ use super::*;
15
+
16
+ // ── lifecycle::launch —— 冷启 / quick-start / 危险审批探测 ──────────────────
17
+
18
+ /// `launch(spec_path, dry_run, auto_approve, skip_profile_smoke)`(`launch/core.py:29`)。
19
+ /// 冷启全队:路由 tasks、resolve 权限/危险审批门、session 冲突检查(冲突 →
20
+ /// `SessionConflict` 拒绝不 kill)、按 startup 顺序起每个 worker、捕获 session、开显示、
21
+ /// 写 state/team_state、attach leader receiver。
22
+ pub fn launch(
23
+ spec_path: &Path,
24
+ dry_run: bool,
25
+ auto_approve: bool,
26
+ skip_profile_smoke: bool,
27
+ ) -> Result<LaunchReport, LifecycleError> {
28
+ // CP-1: bind the spawn backend to the per-team socket (derived from the run workspace, the same
29
+ // path the daemon/CLI derive from) so spawn + later has_session/inject/kill all hit one server.
30
+ let team_dir = spec_path.parent().unwrap_or_else(|| Path::new("."));
31
+ let transport = crate::tmux_backend::TmuxBackend::for_workspace(&team_workspace(team_dir));
32
+ launch_with_transport(
33
+ spec_path,
34
+ dry_run,
35
+ auto_approve,
36
+ skip_profile_smoke,
37
+ &transport,
38
+ )
39
+ }
40
+
41
+ pub fn launch_with_transport(
42
+ spec_path: &Path,
43
+ dry_run: bool,
44
+ auto_approve: bool,
45
+ skip_profile_smoke: bool,
46
+ transport: &dyn Transport,
47
+ ) -> Result<LaunchReport, LifecycleError> {
48
+ let _ = skip_profile_smoke;
49
+ if !spec_path.exists() {
50
+ return Err(LifecycleError::Compile(format!(
51
+ "spec path not found: {}",
52
+ spec_path.display()
53
+ )));
54
+ }
55
+ let text = std::fs::read_to_string(spec_path).map_err(|e| {
56
+ LifecycleError::Compile(format!("{}: {e}", spec_path.display()))
57
+ })?;
58
+ let spec = yaml::loads(&text).map_err(|e| LifecycleError::Compile(e.to_string()))?;
59
+ let session_name = spec_session_name(&spec);
60
+ let safety = effective_runtime_config(&spec)?;
61
+ if safety.enabled && !safety.inherited && !auto_approve && !dry_run {
62
+ return Err(LifecycleError::DangerousApprovalRequired(
63
+ "runtime dangerous_auto_approve is enabled".to_string(),
64
+ ));
65
+ }
66
+ if !dry_run && transport_has_session(transport, &session_name) {
67
+ return Err(LifecycleError::SessionConflict(format!(
68
+ "tmux session already exists: {}",
69
+ session_name.as_str()
70
+ )));
71
+ }
72
+ let permissions = spec_agents(&spec)
73
+ .into_iter()
74
+ .map(|agent| PermissionSummary {
75
+ agent_id: agent,
76
+ raw: serde_json::json!({"source": "compiled_spec"}),
77
+ })
78
+ .collect::<Vec<_>>();
79
+ write_launch_permission_audit(&team_workspace(spec_path.parent().unwrap_or_else(|| Path::new("."))), &safety)?;
80
+ let routes = spec_routes(&spec);
81
+ let started = if dry_run {
82
+ Vec::new()
83
+ } else {
84
+ let started = spawn_agents(spec_path, &spec, &session_name, &safety, transport)?;
85
+ persist_spawn_agent_state(spec_path, &spec, &session_name, transport, &started)?;
86
+ started
87
+ };
88
+ Ok(LaunchReport {
89
+ session_name,
90
+ started,
91
+ dry_run,
92
+ routes,
93
+ permissions,
94
+ safety,
95
+ leader_receiver_attached: false,
96
+ })
97
+ }
98
+
99
+ fn spawn_agents(
100
+ spec_path: &Path,
101
+ spec: &Value,
102
+ session_name: &SessionName,
103
+ safety: &DangerousApproval,
104
+ transport: &dyn Transport,
105
+ ) -> Result<Vec<StartedAgent>, LifecycleError> {
106
+ let team_dir = spec_path.parent().unwrap_or_else(|| Path::new("."));
107
+ let workspace = team_workspace(team_dir);
108
+ let mut started = Vec::new();
109
+ for agent in spec_agent_values(spec) {
110
+ let Some(agent_id_raw) = agent.get("id").and_then(Value::as_str) else {
111
+ continue;
112
+ };
113
+ if agent_is_paused(agent) {
114
+ continue;
115
+ }
116
+ let agent_id = AgentId::new(agent_id_raw);
117
+ let provider = agent
118
+ .get("provider")
119
+ .and_then(Value::as_str)
120
+ .and_then(parse_provider)
121
+ .unwrap_or(Provider::Codex);
122
+ let auth_mode = agent
123
+ .get("auth_mode")
124
+ .and_then(Value::as_str)
125
+ .and_then(parse_auth_mode)
126
+ .unwrap_or(AuthMode::Subscription);
127
+ let model = agent.get("model").and_then(Value::as_str);
128
+ let adapter = crate::provider::get_adapter(provider);
129
+ // Contract C / F6.4: pass the COMPILED agent context (resolved role/system prompt,
130
+ // tools list, per-worker MCP config) into command construction so a real worker
131
+ // has both the role instruction AND the callable Team Agent MCP capability.
132
+ // probe5 RED proved that `build_command(.., None, None, ..)` left the worker
133
+ // without `report_result`; placeholders are substituted at spawn time.
134
+ let role = agent.get("role").and_then(Value::as_str);
135
+ let tools = worker_tool_refs(agent_tool_strings(agent), safety);
136
+ let tool_refs: Vec<&str> = tools.iter().map(String::as_str).collect();
137
+ let mcp_team_id =
138
+ runtime_active_team_key_for_spawn(&workspace, spec_path, spec, session_name);
139
+ let process_team_id = process_team_id_for_spawn(&workspace, spec);
140
+ let mcp_config = adapter
141
+ .mcp_config(auth_mode)
142
+ .map_err(|e| LifecycleError::Provider(e.to_string()))?;
143
+ let mcp_config = resolve_mcp_config(mcp_config, &workspace, agent_id_raw, &mcp_team_id);
144
+ let mcp_config_path = write_worker_mcp_config(&workspace, agent_id_raw, &mcp_config)?;
145
+ let mut argv = adapter
146
+ .build_command_with_tools(
147
+ auth_mode,
148
+ Some(&mcp_config),
149
+ role,
150
+ model,
151
+ &tool_refs,
152
+ )
153
+ .map_err(|e| LifecycleError::Provider(e.to_string()))?;
154
+ point_native_mcp_config_at_file(&mut argv, provider, &mcp_config_path);
155
+ fill_spawn_placeholders_full(
156
+ &mut argv,
157
+ &workspace,
158
+ agent_id_raw,
159
+ process_team_id.as_deref(),
160
+ );
161
+ let window = WindowName::new(agent_id_raw);
162
+ let env = inherited_env_with_team_overrides(
163
+ &workspace,
164
+ agent_id_raw,
165
+ process_team_id.as_deref(),
166
+ );
167
+ let spawn = if started.is_empty() {
168
+ transport.spawn_first(session_name, &window, &argv, team_dir, &env)
169
+ } else {
170
+ transport.spawn_into(session_name, &window, &argv, team_dir, &env)
171
+ }
172
+ .map_err(|e| LifecycleError::Transport(e.to_string()))?;
173
+ let _ = adapter.handle_startup_prompts(
174
+ transport,
175
+ &Target::Pane(spawn.pane_id.clone()),
176
+ 30,
177
+ 0.5,
178
+ );
179
+ if matches!(transport.liveness(&spawn.pane_id), Ok(PaneLiveness::Dead)) {
180
+ continue;
181
+ }
182
+ started.push(StartedAgent {
183
+ agent_id,
184
+ start_mode: StartMode::Fresh,
185
+ target: spawn.pane_id.as_str().to_string(),
186
+ session_id: None,
187
+ rollout_path: None,
188
+ display: WorkerDisplay::Blocked {
189
+ reason: AdaptiveBlockReason::NotImplementedThisPlatform,
190
+ },
191
+ });
192
+ }
193
+ Ok(started)
194
+ }
195
+
196
+ fn persist_spawn_agent_state(
197
+ spec_path: &Path,
198
+ spec: &Value,
199
+ session_name: &SessionName,
200
+ transport: &dyn Transport,
201
+ started: &[StartedAgent],
202
+ ) -> Result<(), LifecycleError> {
203
+ let team_dir = spec_path.parent().unwrap_or_else(|| Path::new("."));
204
+ let workspace = team_workspace(team_dir);
205
+ let state_path = crate::state::persist::runtime_state_path(&workspace);
206
+ let mut state = if state_path.exists() {
207
+ let text = std::fs::read_to_string(&state_path)
208
+ .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", state_path.display())))?;
209
+ serde_json::from_str::<serde_json::Value>(&text)
210
+ .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", state_path.display())))?
211
+ } else {
212
+ serde_json::json!({"agents": {}})
213
+ };
214
+ let team_id = explicit_active_team_key(&state)
215
+ .unwrap_or_else(|| runtime_team_key_for_spec(spec_path, spec, session_name));
216
+ let worker_tmux_socket = launched_worker_tmux_socket(transport, &workspace);
217
+ drop_worker_pane_seeded_owner(
218
+ &mut state,
219
+ &team_id,
220
+ started,
221
+ worker_tmux_socket.as_deref(),
222
+ );
223
+ // Only persist running state for agents whose spawn still has a live target.
224
+ let live_windows: BTreeSet<String> = transport
225
+ .list_windows(session_name)
226
+ .unwrap_or_default()
227
+ .into_iter()
228
+ .map(|w| w.as_str().to_string())
229
+ .collect();
230
+ let live_started_agents: BTreeSet<String> = started
231
+ .iter()
232
+ .map(|agent| agent.agent_id.as_str().to_string())
233
+ .collect();
234
+ let mut agents = serde_json::Map::new();
235
+ let spawned_at = spawn_timestamp();
236
+ for agent in spec_agent_values(spec) {
237
+ let Some(id) = agent.get("id").and_then(Value::as_str) else {
238
+ continue;
239
+ };
240
+ let provider = agent
241
+ .get("provider")
242
+ .and_then(Value::as_str)
243
+ .and_then(parse_provider)
244
+ .unwrap_or(Provider::Codex);
245
+ if agent_is_paused(agent) {
246
+ let mut paused = serde_json::Map::new();
247
+ paused.insert("status".to_string(), serde_json::json!("paused"));
248
+ paused.insert("provider".to_string(), serde_json::json!(provider));
249
+ agents.insert(id.to_string(), serde_json::Value::Object(paused));
250
+ continue;
251
+ }
252
+ let window = agent.get("window").and_then(Value::as_str).unwrap_or(id);
253
+ if !live_started_agents.contains(id)
254
+ || (!live_windows.is_empty() && !live_windows.contains(window))
255
+ {
256
+ let mut failed = serde_json::Map::new();
257
+ failed.insert("status".to_string(), serde_json::json!("spawn_failed"));
258
+ failed.insert("provider".to_string(), serde_json::json!(provider));
259
+ failed.insert("agent_id".to_string(), serde_json::json!(id));
260
+ failed.insert("window".to_string(), serde_json::json!(window));
261
+ failed.insert(
262
+ "reason".to_string(),
263
+ serde_json::json!("tmux window not present after spawn"),
264
+ );
265
+ agents.insert(id.to_string(), serde_json::Value::Object(failed));
266
+ continue;
267
+ }
268
+ agents.insert(
269
+ id.to_string(),
270
+ running_agent_state(agent, id, provider, &workspace, &spawned_at, &team_id)?,
271
+ );
272
+ }
273
+ if let Some(obj) = state.as_object_mut() {
274
+ obj.insert("agents".to_string(), serde_json::Value::Object(agents));
275
+ } else {
276
+ let mut obj = serde_json::Map::new();
277
+ obj.insert("agents".to_string(), serde_json::Value::Object(agents));
278
+ state = serde_json::Value::Object(obj);
279
+ }
280
+ save_launched_team_state(&workspace, &state)
281
+ }
282
+
283
+ fn save_launched_team_state(workspace: &Path, launched: &serde_json::Value) -> Result<(), LifecycleError> {
284
+ let existing = load_runtime_state(workspace).unwrap_or_else(|_| serde_json::json!({}));
285
+ let launched_key = crate::state::projection::team_state_key(launched);
286
+ let mut launched = launched.clone();
287
+ promote_launched_binding_from_team_entry(&mut launched, &launched_key);
288
+ drop_foreign_seeded_owner(&existing, &launched_key, &mut launched);
289
+ let merged = crate::state::projection::merge_workspace_team_state(&existing, &launched);
290
+ let mut projected = crate::state::projection::project_top_level_view(&merged, &launched_key);
291
+ drop_unbound_top_level_owner(&mut projected);
292
+ save_runtime_state(workspace, &projected).map_err(|e| LifecycleError::StatePersist(e.to_string()))
293
+ }
294
+
295
+ fn promote_launched_binding_from_team_entry(launched: &mut serde_json::Value, launched_key: &str) {
296
+ let entry = launched
297
+ .get("teams")
298
+ .and_then(|teams| teams.get(launched_key))
299
+ .cloned();
300
+ let Some(entry) = entry else {
301
+ return;
302
+ };
303
+ let Some(obj) = launched.as_object_mut() else {
304
+ return;
305
+ };
306
+ for key in ["leader_receiver", "team_owner", "owner_epoch"] {
307
+ if !obj.contains_key(key) {
308
+ if let Some(value) = entry.get(key) {
309
+ obj.insert(key.to_string(), value.clone());
310
+ }
311
+ }
312
+ }
313
+ }
314
+
315
+ fn drop_unbound_top_level_owner(state: &mut serde_json::Value) {
316
+ let pane = state
317
+ .get("team_owner")
318
+ .and_then(|owner| owner.get("pane_id"))
319
+ .and_then(serde_json::Value::as_str)
320
+ .unwrap_or("");
321
+ if pane.starts_with('%') || pane.chars().all(|ch| ch.is_ascii_digit()) && !pane.is_empty() {
322
+ return;
323
+ }
324
+ if let Some(obj) = state.as_object_mut() {
325
+ obj.remove("leader_receiver");
326
+ obj.remove("team_owner");
327
+ obj.remove("owner_epoch");
328
+ }
329
+ }
330
+
331
+ fn drop_foreign_seeded_owner(existing: &serde_json::Value, launched_key: &str, launched: &mut serde_json::Value) {
332
+ let Some(pane) = launched
333
+ .get("team_owner")
334
+ .and_then(|owner| owner.get("pane_id"))
335
+ .and_then(serde_json::Value::as_str)
336
+ .filter(|pane| !pane.is_empty())
337
+ else {
338
+ return;
339
+ };
340
+ if owner_pane_belongs_to_other_team(existing, launched_key, pane) {
341
+ seed_unbound_launched_owner(launched, launched_key);
342
+ }
343
+ }
344
+
345
+ fn drop_worker_pane_seeded_owner(
346
+ launched: &mut serde_json::Value,
347
+ launched_key: &str,
348
+ started: &[StartedAgent],
349
+ worker_tmux_socket: Option<&str>,
350
+ ) {
351
+ let Some(pane) = launched
352
+ .get("team_owner")
353
+ .and_then(|owner| owner.get("pane_id"))
354
+ .and_then(serde_json::Value::as_str)
355
+ .filter(|pane| !pane.is_empty())
356
+ else {
357
+ return;
358
+ };
359
+ let leader_pane = std::env::var("TEAM_AGENT_LEADER_PANE_ID")
360
+ .ok()
361
+ .filter(|value| !value.is_empty());
362
+ let tmux_pane = std::env::var("TMUX_PANE")
363
+ .ok()
364
+ .filter(|value| !value.is_empty());
365
+ let has_leader_identity_env = leader_pane.is_some()
366
+ || env_nonempty("TEAM_AGENT_LEADER_SESSION_UUID")
367
+ || env_nonempty("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE")
368
+ || env_nonempty("TEAM_AGENT_LEADER_PROVIDER")
369
+ || env_nonempty("TEAM_AGENT_ID")
370
+ || env_nonempty("TEAM_AGENT_TEAM_ID");
371
+ let seeded_from_bare_tmux =
372
+ !has_leader_identity_env && tmux_pane.as_deref() == Some(pane);
373
+ let caller_tmux_socket = crate::tmux_backend::socket_name_from_tmux_env();
374
+ if seeded_from_bare_tmux
375
+ && tmux_sockets_match_or_unknown(caller_tmux_socket.as_deref(), worker_tmux_socket)
376
+ && started.iter().any(|agent| agent.target == pane)
377
+ {
378
+ seed_unbound_launched_owner(launched, launched_key);
379
+ }
380
+ }
381
+
382
+ fn launched_worker_tmux_socket(
383
+ transport: &dyn Transport,
384
+ workspace: &Path,
385
+ ) -> Option<String> {
386
+ if matches!(transport.kind(), crate::transport::BackendKind::Tmux) {
387
+ Some(crate::tmux_backend::socket_name_for_workspace(workspace))
388
+ } else {
389
+ None
390
+ }
391
+ }
392
+
393
+ fn tmux_sockets_match_or_unknown(
394
+ caller_socket: Option<&str>,
395
+ worker_socket: Option<&str>,
396
+ ) -> bool {
397
+ match (caller_socket, worker_socket) {
398
+ (Some(caller), Some(worker)) => caller == worker,
399
+ (Some(_), None) => false,
400
+ (None, _) => true,
401
+ }
402
+ }
403
+
404
+ fn env_nonempty(key: &str) -> bool {
405
+ std::env::var(key).ok().is_some_and(|value| !value.is_empty())
406
+ }
407
+
408
+ fn seed_unbound_launched_owner(launched: &mut serde_json::Value, launched_key: &str) {
409
+ let provider = launched
410
+ .get("team_owner")
411
+ .and_then(|owner| owner.get("provider"))
412
+ .and_then(serde_json::Value::as_str)
413
+ .filter(|provider| !provider.is_empty())
414
+ .unwrap_or("codex");
415
+ let machine_fingerprint = launched
416
+ .get("team_owner")
417
+ .and_then(|owner| owner.get("machine_fingerprint"))
418
+ .and_then(serde_json::Value::as_str)
419
+ .unwrap_or("");
420
+ let workspace = launched
421
+ .get("workspace")
422
+ .and_then(serde_json::Value::as_str)
423
+ .unwrap_or("");
424
+ let os_user = std::env::var("USER")
425
+ .or_else(|_| std::env::var("USERNAME"))
426
+ .unwrap_or_default();
427
+ let Ok(uuid) = crate::model::ids::LeaderSessionUuid::derive(
428
+ machine_fingerprint,
429
+ workspace,
430
+ &os_user,
431
+ launched_key,
432
+ ) else {
433
+ return;
434
+ };
435
+ let owner_epoch = 1u64;
436
+ let owner = serde_json::json!({
437
+ "pane_id": "__team_agent_unbound__",
438
+ "provider": provider,
439
+ "machine_fingerprint": machine_fingerprint,
440
+ "leader_session_uuid": uuid.as_str(),
441
+ "owner_epoch": owner_epoch,
442
+ "claimed_at": spawn_timestamp(),
443
+ "claimed_via": "quick-start",
444
+ "os_user": os_user,
445
+ });
446
+ let receiver = serde_json::json!({
447
+ "mode": "direct_tmux",
448
+ "status": "attached",
449
+ "provider": provider,
450
+ "pane_id": "__team_agent_unbound__",
451
+ "leader_session_uuid": uuid.as_str(),
452
+ "owner_epoch": owner_epoch,
453
+ "discovery": "quick_start",
454
+ });
455
+ if let Some(obj) = launched.as_object_mut() {
456
+ obj.insert("leader_receiver".to_string(), receiver);
457
+ obj.insert("team_owner".to_string(), owner);
458
+ obj.insert("owner_epoch".to_string(), serde_json::json!(owner_epoch));
459
+ }
460
+ }
461
+
462
+ fn owner_pane_belongs_to_other_team(existing: &serde_json::Value, launched_key: &str, pane: &str) -> bool {
463
+ existing
464
+ .get("teams")
465
+ .and_then(serde_json::Value::as_object)
466
+ .is_some_and(|teams| {
467
+ teams.iter().any(|(key, team)| {
468
+ key != launched_key
469
+ && team
470
+ .get("team_owner")
471
+ .and_then(|owner| owner.get("pane_id"))
472
+ .and_then(serde_json::Value::as_str)
473
+ == Some(pane)
474
+ })
475
+ })
476
+ }
477
+
478
+ fn running_agent_state(
479
+ agent: &Value,
480
+ id: &str,
481
+ provider: Provider,
482
+ workspace: &Path,
483
+ spawned_at: &str,
484
+ team_id: &str,
485
+ ) -> Result<serde_json::Value, LifecycleError> {
486
+ let model = agent.get("model").and_then(Value::as_str);
487
+ let auth_mode = agent
488
+ .get("auth_mode")
489
+ .and_then(Value::as_str)
490
+ .and_then(parse_auth_mode)
491
+ .unwrap_or(AuthMode::Subscription);
492
+ let profile = agent.get("profile").map(yaml_value_to_json).unwrap_or(serde_json::Value::Null);
493
+ let window = agent.get("window").and_then(Value::as_str).unwrap_or(id);
494
+ let mcp_config = crate::provider::get_adapter(provider)
495
+ .mcp_config(auth_mode)
496
+ .map_err(|e| LifecycleError::Provider(e.to_string()))?;
497
+ let mcp_config = resolve_mcp_config(mcp_config, workspace, id, team_id);
498
+ let mcp_config_path = write_worker_mcp_config(workspace, id, &mcp_config)?;
499
+ let mut state = serde_json::Map::new();
500
+ state.insert("status".to_string(), serde_json::json!("running"));
501
+ state.insert("provider".to_string(), serde_json::json!(provider));
502
+ state.insert("agent_id".to_string(), serde_json::json!(id));
503
+ state.insert("model".to_string(), model.map_or(serde_json::Value::Null, |m| serde_json::json!(m)));
504
+ state.insert("auth_mode".to_string(), serde_json::json!(auth_mode));
505
+ state.insert("profile".to_string(), profile);
506
+ state.insert("window".to_string(), serde_json::json!(window));
507
+ state.insert(
508
+ "mcp_config".to_string(),
509
+ serde_json::json!(mcp_config_path.to_string_lossy().to_string()),
510
+ );
511
+ state.insert(
512
+ "permissions".to_string(),
513
+ permissions_json(agent, id, provider)
514
+ .map_err(|e| LifecycleError::Compile(e.to_string()))?,
515
+ );
516
+ state.insert("session_id".to_string(), serde_json::Value::Null);
517
+ state.insert("rollout_path".to_string(), serde_json::Value::Null);
518
+ state.insert("captured_at".to_string(), serde_json::Value::Null);
519
+ state.insert("captured_via".to_string(), serde_json::Value::Null);
520
+ state.insert("attribution_confidence".to_string(), serde_json::Value::Null);
521
+ state.insert(
522
+ "spawn_cwd".to_string(),
523
+ serde_json::json!(workspace.to_string_lossy().to_string()),
524
+ );
525
+ state.insert("spawned_at".to_string(), serde_json::json!(spawned_at));
526
+ Ok(serde_json::Value::Object(state))
527
+ }
528
+
529
+ fn resolve_mcp_config(
530
+ config: crate::provider::McpConfig,
531
+ workspace: &Path,
532
+ agent_id: &str,
533
+ team_id: &str,
534
+ ) -> crate::provider::McpConfig {
535
+ crate::provider::McpConfig {
536
+ raw: resolve_mcp_placeholders(config.raw, workspace, agent_id, team_id),
537
+ }
538
+ }
539
+
540
+ fn resolve_mcp_placeholders(
541
+ value: serde_json::Value,
542
+ workspace: &Path,
543
+ agent_id: &str,
544
+ team_id: &str,
545
+ ) -> serde_json::Value {
546
+ match value {
547
+ serde_json::Value::String(s) => serde_json::Value::String(
548
+ s.replace("{workspace}", &workspace.to_string_lossy())
549
+ .replace("{agent_id}", agent_id)
550
+ .replace("{team_id}", team_id),
551
+ ),
552
+ serde_json::Value::Array(items) => serde_json::Value::Array(
553
+ items
554
+ .into_iter()
555
+ .map(|item| resolve_mcp_placeholders(item, workspace, agent_id, team_id))
556
+ .collect(),
557
+ ),
558
+ serde_json::Value::Object(map) => serde_json::Value::Object(
559
+ map.into_iter()
560
+ .map(|(key, value)| {
561
+ (
562
+ key,
563
+ resolve_mcp_placeholders(value, workspace, agent_id, team_id),
564
+ )
565
+ })
566
+ .collect(),
567
+ ),
568
+ other => other,
569
+ }
570
+ }
571
+
572
+ fn write_worker_mcp_config(
573
+ workspace: &Path,
574
+ agent_id: &str,
575
+ config: &crate::provider::McpConfig,
576
+ ) -> Result<PathBuf, LifecycleError> {
577
+ let path = workspace
578
+ .join(".team/runtime/mcp")
579
+ .join(format!("{agent_id}.json"));
580
+ if let Some(parent) = path.parent() {
581
+ std::fs::create_dir_all(parent)
582
+ .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", parent.display())))?;
583
+ }
584
+ let body = serde_json::to_string_pretty(&serde_json::json!({"mcpServers": config.raw}))
585
+ .map_err(|e| LifecycleError::StatePersist(format!("serialize mcp config: {e}")))?;
586
+ std::fs::write(&path, body)
587
+ .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", path.display())))?;
588
+ Ok(path)
589
+ }
590
+
591
+ fn point_native_mcp_config_at_file(argv: &mut [String], provider: Provider, path: &Path) {
592
+ if !matches!(provider, Provider::Claude | Provider::ClaudeCode) {
593
+ return;
594
+ }
595
+ let Some(index) = argv.iter().position(|arg| arg == "--mcp-config") else {
596
+ return;
597
+ };
598
+ if let Some(value) = argv.get_mut(index.saturating_add(1)) {
599
+ *value = path.to_string_lossy().to_string();
600
+ }
601
+ }
602
+
603
+ fn permissions_json(
604
+ agent: &Value,
605
+ id: &str,
606
+ provider: Provider,
607
+ ) -> Result<serde_json::Value, crate::model::ModelError> {
608
+ let tools = agent.get("tools").and_then(Value::as_list).map(|items| {
609
+ items
610
+ .iter()
611
+ .filter_map(Value::as_str)
612
+ .map(str::to_string)
613
+ .collect::<Vec<_>>()
614
+ });
615
+ let resolved = permissions::resolve_permissions(&AgentPermissionInput {
616
+ id: Some(AgentId::new(id)),
617
+ provider,
618
+ role: agent.get("role").and_then(Value::as_str).map(str::to_string),
619
+ tools,
620
+ })?;
621
+ let mut out = serde_json::Map::new();
622
+ out.insert("agent_id".to_string(), serde_json::json!(id));
623
+ out.insert("provider".to_string(), serde_json::json!(provider));
624
+ out.insert("tools".to_string(), serde_json::json!(resolved.sorted_tool_strings()));
625
+ out.insert(
626
+ "resolved_tools".to_string(),
627
+ serde_json::Value::Array(
628
+ resolved
629
+ .resolved_tools
630
+ .iter()
631
+ .map(|tool| {
632
+ serde_json::json!({
633
+ "tool": tool.tool,
634
+ "enforcement": tool.enforcement,
635
+ })
636
+ })
637
+ .collect(),
638
+ ),
639
+ );
640
+ out.insert("has_prompt_only".to_string(), serde_json::json!(resolved.has_prompt_only));
641
+ Ok(serde_json::Value::Object(out))
642
+ }
643
+
644
+ fn agent_is_paused(agent: &Value) -> bool {
645
+ matches!(agent.get("paused"), Some(Value::Bool(true)))
646
+ }
647
+
648
+ fn spawn_timestamp() -> String {
649
+ match std::env::var("TEAM_AGENT_TEST_FIXED_SPAWNED_AT") {
650
+ Ok(value) => value,
651
+ Err(_) => chrono::Utc::now()
652
+ .format("%Y-%m-%dT%H:%M:%S%.6f+00:00")
653
+ .to_string(),
654
+ }
655
+ }
656
+
657
+ pub(crate) fn fill_spawn_placeholders(argv: &mut [String], workspace: &Path, agent_id: &str) {
658
+ fill_spawn_placeholders_full(argv, workspace, agent_id, None);
659
+ }
660
+
661
+ /// #229 B-layer worker env contract (`worker_spawn_inherits_parent_process_env_for_proxy_and_ca`):
662
+ /// every worker `transport.spawn_first/into` MUST receive an env map that is the **complete**
663
+ /// `team-agent` process environ (so the child sees the user's PATH ordering, HTTP_PROXY /
664
+ /// HTTPS_PROXY / ALL_PROXY / NO_PROXY, NODE_EXTRA_CA_CERTS / SSL_CERT_FILE / CURL_CA_BUNDLE /
665
+ /// REQUESTS_CA_BUNDLE / GIT_SSL_CAINFO, plus any wrapper-sourced vars), **then** overlay the
666
+ /// Team Agent identity three-tuple. This equals POSIX "child inherits parent environ" — the same
667
+ /// behavior the user gets when typing `codex` from their own shell. Zero hardcoded paths, zero
668
+ /// wrapper-name assumptions, generic across providers.
669
+ ///
670
+ /// `TMUX` / `TMUX_PANE` are stripped because they bind the inherited shell to the **launching**
671
+ /// tmux pane; leaving them in would point worker-side tmux integrations at the wrong pane.
672
+ pub(crate) fn inherited_env_with_team_overrides(
673
+ workspace: &Path,
674
+ agent_id: &str,
675
+ team_id: Option<&str>,
676
+ ) -> BTreeMap<String, String> {
677
+ // Only POSIX-valid shell identifier keys ([A-Za-z_][A-Za-z0-9_]*) — Bash/dash refuses
678
+ // `KEY=val` assignment whose KEY has dashes/dots (e.g. `CARGO_BIN_EXE_team-agent=...`
679
+ // shipped by cargo's integration-test runner) and would fail the entire `sh -lc`
680
+ // line, leaving tmux's session dead-on-arrival. POSIX-invalid keys are runtime
681
+ // metadata that workers never legitimately need; the user's PATH/proxy/CA always use
682
+ // valid identifiers.
683
+ let mut env: BTreeMap<String, String> = std::env::vars()
684
+ .filter(|(k, _)| is_posix_shell_identifier(k))
685
+ .collect();
686
+ env.remove("TMUX");
687
+ env.remove("TMUX_PANE");
688
+ env.insert(
689
+ "TEAM_AGENT_WORKSPACE".to_string(),
690
+ workspace.to_string_lossy().to_string(),
691
+ );
692
+ env.insert("TEAM_AGENT_AGENT_ID".to_string(), agent_id.to_string());
693
+ if let Some(tid) = team_id.filter(|s| !s.is_empty()) {
694
+ env.insert("TEAM_AGENT_OWNER_TEAM_ID".to_string(), tid.to_string());
695
+ }
696
+ env
697
+ }
698
+
699
+ fn is_posix_shell_identifier(name: &str) -> bool {
700
+ let mut chars = name.chars();
701
+ match chars.next() {
702
+ Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
703
+ _ => return false,
704
+ }
705
+ chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
706
+ }
707
+
708
+ /// Same as [`fill_spawn_placeholders`] plus `{team_id}` substitution everywhere it
709
+ /// appears as a SUBSTRING (the MCP config encodes it as `mcp_servers.team_orchestrator
710
+ /// .env.TEAM_AGENT_OWNER_TEAM_ID="{team_id}"`, embedded inside `-c key=value` strings,
711
+ /// so a token-equality replace would miss it).
712
+ pub(crate) fn fill_spawn_placeholders_full(
713
+ argv: &mut [String],
714
+ workspace: &Path,
715
+ agent_id: &str,
716
+ team_id: Option<&str>,
717
+ ) {
718
+ let workspace_text = workspace.to_string_lossy().to_string();
719
+ let team_text = team_id.unwrap_or("").to_string();
720
+ for arg in argv {
721
+ if arg == "{workspace}" {
722
+ *arg = workspace_text.clone();
723
+ } else if arg == "{agent_id}" {
724
+ *arg = agent_id.to_string();
725
+ } else if arg.contains("{workspace}") || arg.contains("{agent_id}") || arg.contains("{team_id}") {
726
+ *arg = arg
727
+ .replace("{workspace}", &workspace_text)
728
+ .replace("{agent_id}", agent_id)
729
+ .replace("{team_id}", &team_text);
730
+ }
731
+ }
732
+ }
733
+
734
+ fn agent_tool_strings(agent: &Value) -> Vec<String> {
735
+ agent
736
+ .get("tools")
737
+ .and_then(Value::as_list)
738
+ .map(|items| {
739
+ items
740
+ .iter()
741
+ .filter_map(Value::as_str)
742
+ .map(str::to_string)
743
+ .collect()
744
+ })
745
+ .unwrap_or_default()
746
+ }
747
+
748
+ fn spec_team_id(spec: &Value) -> Option<String> {
749
+ spec.get("team")
750
+ .and_then(|v| v.get("id").or_else(|| v.get("name")))
751
+ .and_then(Value::as_str)
752
+ .map(str::to_string)
753
+ .or_else(|| {
754
+ spec.get("name")
755
+ .and_then(Value::as_str)
756
+ .map(str::to_string)
757
+ })
758
+ }
759
+
760
+ fn runtime_active_team_key_for_spawn(
761
+ workspace: &Path,
762
+ spec_path: &Path,
763
+ spec: &Value,
764
+ session_name: &SessionName,
765
+ ) -> String {
766
+ load_runtime_state(workspace)
767
+ .ok()
768
+ .and_then(|state| explicit_active_team_key(&state))
769
+ .unwrap_or_else(|| runtime_team_key_for_spec(spec_path, spec, session_name))
770
+ }
771
+
772
+ fn process_team_id_for_spawn(workspace: &Path, spec: &Value) -> Option<String> {
773
+ load_runtime_state(workspace)
774
+ .ok()
775
+ .and_then(|state| explicit_active_team_key(&state))
776
+ .or_else(|| spec_team_id(spec))
777
+ }
778
+
779
+ fn explicit_active_team_key(state: &serde_json::Value) -> Option<String> {
780
+ state
781
+ .get("active_team_key")
782
+ .and_then(serde_json::Value::as_str)
783
+ .filter(|team| !team.is_empty() && *team != "current")
784
+ .map(str::to_string)
785
+ }
786
+
787
+ fn runtime_team_key_for_spec(spec_path: &Path, spec: &Value, session_name: &SessionName) -> String {
788
+ let team_dir = spec_path.parent().unwrap_or_else(|| Path::new("."));
789
+ let state = serde_json::json!({
790
+ "team_dir": team_dir.to_string_lossy(),
791
+ "spec_path": spec_path.to_string_lossy(),
792
+ "session_name": session_name.as_str(),
793
+ "team": spec.get("team").map(yaml_value_to_json).unwrap_or(serde_json::Value::Null),
794
+ });
795
+ crate::state::projection::team_state_key(&state)
796
+ }
797
+
798
+ fn transport_has_session(transport: &dyn Transport, session_name: &SessionName) -> bool {
799
+ match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
800
+ transport.has_session(session_name)
801
+ })) {
802
+ Ok(Ok(live)) => live,
803
+ Ok(Err(_)) | Err(_) => false,
804
+ }
805
+ }
806
+
807
+ fn parse_provider(raw: &str) -> Option<Provider> {
808
+ match raw {
809
+ "claude" => Some(Provider::Claude),
810
+ "claude_code" => Some(Provider::ClaudeCode),
811
+ "codex" => Some(Provider::Codex),
812
+ "gemini_cli" => Some(Provider::GeminiCli),
813
+ "fake" => Some(Provider::Fake),
814
+ _ => None,
815
+ }
816
+ }
817
+
818
+ fn parse_auth_mode(raw: &str) -> Option<AuthMode> {
819
+ match raw {
820
+ "subscription" => Some(AuthMode::Subscription),
821
+ "official_api" => Some(AuthMode::OfficialApi),
822
+ "compatible_api" => Some(AuthMode::CompatibleApi),
823
+ _ => None,
824
+ }
825
+ }
826
+
827
+ /// `quick_start(agents_dir, name, yes, fresh, team_id)`(`diagnose/quick_start.py:18`)。
828
+ /// 面向用户的零配置入口:编译 team_dir → `launch` → autobind leader receiver → 起
829
+ /// coordinator → `wait_ready` 轮询就绪。归入 lifecycle module(不与 diagnose 混)。
830
+ pub fn quick_start(
831
+ agents_dir: &Path,
832
+ name: Option<&str>,
833
+ yes: bool,
834
+ fresh: bool,
835
+ team_id: Option<&str>,
836
+ ) -> Result<QuickStartReport, LifecycleError> {
837
+ quick_start_with_transport(
838
+ agents_dir,
839
+ name,
840
+ yes,
841
+ fresh,
842
+ team_id,
843
+ // CP-1: per-team socket bound to the run workspace (team_workspace(agents_dir)).
844
+ &crate::tmux_backend::TmuxBackend::for_workspace(&team_workspace(agents_dir)),
845
+ )
846
+ }
847
+
848
+ /// `quick_start` with an injected transport — tests inject a recording mock so the REAL spawn path
849
+ /// (launch dry_run=false → spawn_agents) is asserted without a live tmux; prod uses the real TmuxBackend.
850
+ pub fn quick_start_with_transport(
851
+ agents_dir: &Path,
852
+ name: Option<&str>,
853
+ yes: bool,
854
+ fresh: bool,
855
+ team_id: Option<&str>,
856
+ transport: &dyn Transport,
857
+ ) -> Result<QuickStartReport, LifecycleError> {
858
+ if !agents_dir.exists() {
859
+ return Err(LifecycleError::Compile(format!(
860
+ "agents dir not found: {}",
861
+ agents_dir.display()
862
+ )));
863
+ }
864
+ let workspace = team_workspace(agents_dir);
865
+ if !fresh {
866
+ let state_path = crate::state::persist::runtime_state_path(&workspace);
867
+ if state_path.exists() {
868
+ let state = crate::state::persist::load_runtime_state(&workspace)
869
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
870
+ return Ok(QuickStartReport::ExistingRuntime {
871
+ team: team_id.map(str::to_string),
872
+ session_name: state
873
+ .get("session_name")
874
+ .and_then(serde_json::Value::as_str)
875
+ .filter(|s| !s.is_empty())
876
+ .map(SessionName::new),
877
+ state_path: Some(state_path),
878
+ next_actions: vec![
879
+ "run restart to resume the existing team or pass --fresh to replace it".to_string(),
880
+ ],
881
+ });
882
+ }
883
+ }
884
+ let mut spec = crate::compiler::compile_team(agents_dir)
885
+ .map_err(|e| LifecycleError::Compile(e.to_string()))?;
886
+ // CR-040/042: repeated quick-start from one template with distinct --team-id/--name
887
+ // must NOT collide on the template-derived tmux session. Override the compiled
888
+ // spec's runtime.session_name with one derived from the REQUESTED team identity
889
+ // so launch_with_transport (which reads runtime.session_name) spawns into an
890
+ // isolated session per requested team.
891
+ if let Some(requested) = team_id.or(name).filter(|s| !s.is_empty()) {
892
+ override_spec_session_name(&mut spec, &format!("team-{requested}"));
893
+ }
894
+ let spec_path = agents_dir.join("team.spec.yaml");
895
+ std::fs::write(&spec_path, yaml::dumps(&spec)).map_err(|e| {
896
+ LifecycleError::StatePersist(format!("{}: {e}", spec_path.display()))
897
+ })?;
898
+ let _store = crate::message_store::MessageStore::open(&workspace)
899
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
900
+ let session_name = spec_session_name(&spec);
901
+ let resolved_spec_path = std::fs::canonicalize(&spec_path).unwrap_or_else(|_| spec_path.clone());
902
+ let state = initial_runtime_state(&spec, &resolved_spec_path, &workspace, agents_dir);
903
+ save_launched_team_state(&workspace, &state)?;
904
+ // FIX (rt-host-a real-machine finding): dry_run=false so launch_with_transport calls spawn_agents
905
+ // and really creates the tmux session + worker windows (was hardcoded true → never spawned, which
906
+ // also starved the coordinator: no session → first tick TmuxSessionMissing → run_daemon loop exits).
907
+ let launch = launch_with_transport(&spec_path, false, yes, true, transport)?;
908
+ let coordinator_workspace = crate::coordinator::WorkspacePath::new(workspace.clone());
909
+ let coordinator_started = crate::coordinator::start_coordinator(&coordinator_workspace)
910
+ .map(|report| report.ok)
911
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
912
+ let coordinator_action = if coordinator_started {
913
+ "coordinator started"
914
+ } else {
915
+ "coordinator not started"
916
+ };
917
+ // BUG-7: build an honest readiness verdict from the post-spawn runtime state.
918
+ // - If persist_spawn_agent_state (BUG-2 fix) marked any agent non-running, the
919
+ // team is observably Degraded.
920
+ // - Otherwise the framework cannot itself verify that the worker's MCP tool set
921
+ // loaded successfully (provider-side codex/claude schema rejections happen
922
+ // asynchronously after spawn), so the verdict is PendingToolLoad — never
923
+ // bare Ready.
924
+ let worker_readiness = quick_start_worker_readiness(&workspace);
925
+ Ok(QuickStartReport::Ready {
926
+ session_name,
927
+ launch: Box::new(launch),
928
+ next_actions: vec![format!(
929
+ "team compiled; real spawn is behind the transport/provider boundary; {coordinator_action}"
930
+ )],
931
+ worker_readiness,
932
+ })
933
+ }
934
+
935
+ /// BUG-7 helper: derive a [`QuickStartReadiness`] verdict from the just-written
936
+ /// runtime state. Reads `agents[*].status`; any non-`running` agent flips the
937
+ /// verdict to `Degraded { unhealthy_agents }` (sorted, deduped); otherwise
938
+ /// `PendingToolLoad` — never bare Ready. State read failure is treated as
939
+ /// PendingToolLoad rather than fabricated success.
940
+ fn quick_start_worker_readiness(workspace: &Path) -> QuickStartReadiness {
941
+ let Ok(state) = load_runtime_state(workspace) else {
942
+ return QuickStartReadiness::PendingToolLoad;
943
+ };
944
+ let Some(agents) = state.get("agents").and_then(serde_json::Value::as_object) else {
945
+ return QuickStartReadiness::PendingToolLoad;
946
+ };
947
+ let mut unhealthy: Vec<String> = agents
948
+ .iter()
949
+ .filter_map(|(id, agent)| {
950
+ let status = agent.get("status").and_then(serde_json::Value::as_str);
951
+ match status {
952
+ Some("running") => None,
953
+ _ => Some(id.clone()),
954
+ }
955
+ })
956
+ .collect();
957
+ if unhealthy.is_empty() {
958
+ QuickStartReadiness::PendingToolLoad
959
+ } else {
960
+ unhealthy.sort();
961
+ unhealthy.dedup();
962
+ QuickStartReadiness::Degraded { unhealthy_agents: unhealthy }
963
+ }
964
+ }
965
+
966
+ /// `detect_inherited_dangerous_permissions`(`launch/config.py`):扫进程祖先链找
967
+ /// `--dangerously-*` flag,产出危险审批继承态。launch 在 inherited=false 且无 --yes 时拒。
968
+ pub fn detect_dangerous_approval() -> Result<DangerousApproval, LifecycleError> {
969
+ if let Ok(raw) = std::env::var("TEAM_AGENT_TEST_PROCESS_ANCESTRY_ARGV_JSON") {
970
+ let argv_tokens = serde_json::from_str::<Vec<String>>(&raw)
971
+ .map_err(|e| LifecycleError::StatePersist(format!("invalid test ancestry argv: {e}")))?;
972
+ return Ok(detect_dangerous_approval_in_argv(&argv_tokens).unwrap_or_else(disabled_dangerous_approval));
973
+ }
974
+ for argv_tokens in process_ancestry_argv(std::process::id()) {
975
+ if let Some(detected) = detect_dangerous_approval_in_argv(&argv_tokens) {
976
+ return Ok(detected);
977
+ }
978
+ }
979
+ Ok(disabled_dangerous_approval())
980
+ }
981
+
982
+ fn detect_dangerous_approval_in_argv(argv_tokens: &[String]) -> Option<DangerousApproval> {
983
+ let argv0 = argv_tokens.first().map(String::as_str).unwrap_or("");
984
+ let ancestry_binary_name = binary_name(argv0);
985
+ for token in argv_tokens {
986
+ for (provider, flag) in dangerous_leader_flags() {
987
+ if token == flag {
988
+ let unexpected_binary = !binary_matches_provider(provider, ancestry_binary_name.as_deref());
989
+ return Some(DangerousApproval {
990
+ enabled: true,
991
+ source: DangerousApprovalSource::LeaderProcess,
992
+ inherited: true,
993
+ provider: Some((*provider).to_string()),
994
+ flag: Some((*flag).to_string()),
995
+ worker_capability_above_leader: false,
996
+ ancestry_binary_name,
997
+ unexpected_binary,
998
+ });
999
+ }
1000
+ }
1001
+ }
1002
+ None
1003
+ }
1004
+
1005
+ fn dangerous_leader_flags() -> &'static [(&'static str, &'static str)] {
1006
+ &[
1007
+ ("claude", "--dangerously-skip-permissions"),
1008
+ ("claude", "--dangerously-skip-permission"),
1009
+ ("codex", "--dangerously-bypass-approvals-and-sandbox"),
1010
+ ]
1011
+ }
1012
+
1013
+ fn binary_matches_provider(provider: &str, binary: Option<&str>) -> bool {
1014
+ match (provider, binary) {
1015
+ ("codex", Some("codex")) => true,
1016
+ ("claude", Some("claude" | "claude-code" | "claude_code")) => true,
1017
+ _ => false,
1018
+ }
1019
+ }
1020
+
1021
+ fn binary_name(argv0: &str) -> Option<String> {
1022
+ Path::new(argv0)
1023
+ .file_name()
1024
+ .and_then(|v| v.to_str())
1025
+ .filter(|s| !s.is_empty())
1026
+ .map(str::to_string)
1027
+ }
1028
+
1029
+ fn process_ancestry_argv(pid: u32) -> Vec<Vec<String>> {
1030
+ let mut out = Vec::new();
1031
+ let mut current = pid;
1032
+ let mut seen = std::collections::BTreeSet::new();
1033
+ for _ in 0..12 {
1034
+ if current == 0 || !seen.insert(current) {
1035
+ break;
1036
+ }
1037
+ if let Some(argv_tokens) = process_argv_tokens(current) {
1038
+ out.push(argv_tokens);
1039
+ }
1040
+ let Some(parent) = process_parent_pid(current) else {
1041
+ break;
1042
+ };
1043
+ if parent <= 1 || parent == current {
1044
+ break;
1045
+ }
1046
+ current = parent;
1047
+ }
1048
+ out
1049
+ }
1050
+
1051
+ #[cfg(target_os = "linux")]
1052
+ fn process_argv_tokens(pid: u32) -> Option<Vec<String>> {
1053
+ let bytes = std::fs::read(format!("/proc/{pid}/cmdline")).ok()?;
1054
+ let argv_tokens = String::from_utf8_lossy(&bytes)
1055
+ .split('\0')
1056
+ .filter(|token| !token.is_empty())
1057
+ .map(str::to_string)
1058
+ .collect::<Vec<_>>();
1059
+ (!argv_tokens.is_empty()).then_some(argv_tokens)
1060
+ }
1061
+
1062
+ #[cfg(target_os = "macos")]
1063
+ fn process_argv_tokens(pid: u32) -> Option<Vec<String>> {
1064
+ use std::mem::size_of;
1065
+
1066
+ let mut mib = [
1067
+ libc::CTL_KERN,
1068
+ libc::KERN_PROCARGS2,
1069
+ i32::try_from(pid).ok()?,
1070
+ ];
1071
+ let mut size = 0usize;
1072
+ let rc = unsafe {
1073
+ libc::sysctl(
1074
+ mib.as_mut_ptr(),
1075
+ mib.len() as u32,
1076
+ std::ptr::null_mut(),
1077
+ &mut size,
1078
+ std::ptr::null_mut(),
1079
+ 0,
1080
+ )
1081
+ };
1082
+ if rc != 0 || size <= size_of::<libc::c_int>() {
1083
+ return None;
1084
+ }
1085
+ let mut buf = vec![0u8; size];
1086
+ let rc = unsafe {
1087
+ libc::sysctl(
1088
+ mib.as_mut_ptr(),
1089
+ mib.len() as u32,
1090
+ buf.as_mut_ptr().cast(),
1091
+ &mut size,
1092
+ std::ptr::null_mut(),
1093
+ 0,
1094
+ )
1095
+ };
1096
+ if rc != 0 || size <= size_of::<libc::c_int>() {
1097
+ return None;
1098
+ }
1099
+ let argc = i32::from_ne_bytes(buf.get(..size_of::<libc::c_int>())?.try_into().ok()?) as usize;
1100
+ let mut offset = size_of::<libc::c_int>();
1101
+ while offset < size && buf[offset] != 0 {
1102
+ offset += 1;
1103
+ }
1104
+ while offset < size && buf[offset] == 0 {
1105
+ offset += 1;
1106
+ }
1107
+ let raw = String::from_utf8_lossy(&buf[offset..size]);
1108
+ let argv_tokens = raw
1109
+ .split('\0')
1110
+ .filter(|token| !token.is_empty())
1111
+ .take(argc)
1112
+ .map(str::to_string)
1113
+ .collect::<Vec<_>>();
1114
+ (!argv_tokens.is_empty()).then_some(argv_tokens)
1115
+ }
1116
+
1117
+ #[cfg(not(any(target_os = "linux", target_os = "macos")))]
1118
+ fn process_argv_tokens(pid: u32) -> Option<Vec<String>> {
1119
+ let output = Command::new("ps")
1120
+ .args(["-p", &pid.to_string(), "-o", "command="])
1121
+ .output()
1122
+ .ok()?;
1123
+ if !output.status.success() {
1124
+ return None;
1125
+ }
1126
+ let text = String::from_utf8_lossy(&output.stdout);
1127
+ let argv_tokens = text
1128
+ .split_whitespace()
1129
+ .filter(|token| !token.is_empty())
1130
+ .map(str::to_string)
1131
+ .collect::<Vec<_>>();
1132
+ (!argv_tokens.is_empty()).then_some(argv_tokens)
1133
+ }
1134
+
1135
+ fn process_parent_pid(pid: u32) -> Option<u32> {
1136
+ let output = Command::new("ps")
1137
+ .args(["-p", &pid.to_string(), "-o", "ppid="])
1138
+ .output()
1139
+ .ok()?;
1140
+ if !output.status.success() {
1141
+ return None;
1142
+ }
1143
+ String::from_utf8_lossy(&output.stdout)
1144
+ .trim()
1145
+ .parse::<u32>()
1146
+ .ok()
1147
+ }
1148
+
1149
+ /// `add_agent(workspace, agent_id, role_file_path, open_display, team)`
1150
+ /// (`lifecycle/operations.py:143`)。动态 role doc 编译进 spec + 起 worker;失败**字节级回滚**
1151
+ /// spec_yaml / workspace_state / **team_state.md** / role_file(Gap 15.11),每步发
1152
+ /// `lifecycle.add_step_*` 事件(顺序被测试锁死)。
1153
+ pub fn add_agent(
1154
+ workspace: &Path,
1155
+ agent_id: &AgentId,
1156
+ role_file_path: &Path,
1157
+ open_display: bool,
1158
+ team: Option<&str>,
1159
+ ) -> Result<AddAgentReport, LifecycleError> {
1160
+ let selected = match crate::state::selector::resolve_active_team(
1161
+ workspace,
1162
+ team,
1163
+ crate::state::selector::SelectorMode::RequireSpec,
1164
+ ) {
1165
+ Ok(selected) => selected,
1166
+ Err(_) if workspace.join("TEAM.md").exists() => {
1167
+ return add_agent_with_transport(
1168
+ workspace,
1169
+ agent_id,
1170
+ role_file_path,
1171
+ open_display,
1172
+ team,
1173
+ &crate::tmux_backend::TmuxBackend::for_workspace(&team_workspace(workspace)),
1174
+ );
1175
+ }
1176
+ Err(error) => return Err(LifecycleError::TeamSelect(error.to_string())),
1177
+ };
1178
+ let team_dir = selected
1179
+ .spec_workspace
1180
+ .ok_or_else(|| LifecycleError::TeamSelect("active team spec workspace not found".to_string()))?;
1181
+ add_agent_with_transport(
1182
+ &team_dir,
1183
+ agent_id,
1184
+ role_file_path,
1185
+ open_display,
1186
+ team,
1187
+ &crate::tmux_backend::TmuxBackend::for_workspace(&selected.run_workspace),
1188
+ )
1189
+ }
1190
+
1191
+ /// `add_agent` with an injected transport — after the recompile+write, wires the new worker spawn
1192
+ /// (via start_agent_with_transport) + start_coordinator (rt-host-a sweep: recompiled but never spawned).
1193
+ pub fn add_agent_with_transport(
1194
+ workspace: &Path,
1195
+ agent_id: &AgentId,
1196
+ role_file_path: &Path,
1197
+ open_display: bool,
1198
+ team: Option<&str>,
1199
+ transport: &dyn Transport,
1200
+ ) -> Result<AddAgentReport, LifecycleError> {
1201
+ let run_workspace = crate::model::paths::canonical_run_workspace(workspace)
1202
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
1203
+ let owner_state = if team.is_some() {
1204
+ crate::state::projection::select_runtime_state(&run_workspace, team)
1205
+ .map_err(|e| LifecycleError::TeamSelect(e.to_string()))?
1206
+ } else {
1207
+ load_runtime_state(&run_workspace).map_err(|e| LifecycleError::StatePersist(e.to_string()))?
1208
+ };
1209
+ ensure_owner_allowed_for_state(&owner_state, Some(agent_id))?;
1210
+ if !role_file_path.exists() {
1211
+ return Err(LifecycleError::Compile(format!(
1212
+ "role file not found: {}",
1213
+ role_file_path.display()
1214
+ )));
1215
+ }
1216
+ let team_dir = workspace;
1217
+ if agent_id_exists_in_team_dir(team_dir, agent_id) {
1218
+ return Err(LifecycleError::RequirementUnmet(format!(
1219
+ "agent id already exists: {agent_id}"
1220
+ )));
1221
+ }
1222
+ let dynamic_role_file = materialize_added_role_file(team_dir, agent_id, role_file_path)?;
1223
+ let spec = crate::compiler::compile_team(team_dir)
1224
+ .map_err(|e| LifecycleError::Compile(e.to_string()))?;
1225
+ let spec_path = team_dir.join("team.spec.yaml");
1226
+ std::fs::write(&spec_path, yaml::dumps(&spec)).map_err(|e| {
1227
+ LifecycleError::StatePersist(format!("{}: {e}", spec_path.display()))
1228
+ })?;
1229
+ let (meta, _) = crate::compiler::read_front_matter(&dynamic_role_file)
1230
+ .map_err(|e| LifecycleError::Compile(e.to_string()))?;
1231
+ let run_ws = team_workspace(team_dir);
1232
+ upsert_agent_state_from_role(&run_ws, agent_id, &meta, &dynamic_role_file)?;
1233
+ let started = crate::lifecycle::restart::start_agent_at_paths(
1234
+ &run_ws,
1235
+ team_dir,
1236
+ agent_id,
1237
+ false,
1238
+ open_display,
1239
+ true,
1240
+ team,
1241
+ transport,
1242
+ )?;
1243
+ let (env, start_mode) = match started {
1244
+ StartAgentOutcome::Running {
1245
+ env, start_mode, ..
1246
+ } => (env, start_mode),
1247
+ StartAgentOutcome::Noop { env, .. } => (env, StartMode::Noop),
1248
+ StartAgentOutcome::Paused { .. } => {
1249
+ return Err(LifecycleError::RequirementUnmet(format!(
1250
+ "added agent {agent_id} is paused"
1251
+ )));
1252
+ }
1253
+ };
1254
+ Ok(AddAgentReport {
1255
+ env,
1256
+ start_mode,
1257
+ role_file: role_file_path.to_path_buf(),
1258
+ })
1259
+ }
1260
+
1261
+ fn upsert_agent_state_from_role(
1262
+ workspace: &Path,
1263
+ agent_id: &AgentId,
1264
+ meta: &Value,
1265
+ dynamic_role_file: &Path,
1266
+ ) -> Result<(), LifecycleError> {
1267
+ let mut state = crate::state::persist::load_runtime_state(workspace)
1268
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
1269
+ if !state.is_object() {
1270
+ state = serde_json::json!({});
1271
+ }
1272
+ let Some(root) = state.as_object_mut() else {
1273
+ return Err(LifecycleError::StatePersist(
1274
+ "runtime state root is not an object".to_string(),
1275
+ ));
1276
+ };
1277
+ let agents = root
1278
+ .entry("agents".to_string())
1279
+ .or_insert_with(|| serde_json::json!({}));
1280
+ if !agents.is_object() {
1281
+ *agents = serde_json::json!({});
1282
+ }
1283
+ let Some(agent_map) = agents.as_object_mut() else {
1284
+ return Err(LifecycleError::StatePersist(
1285
+ "runtime state agents is not an object".to_string(),
1286
+ ));
1287
+ };
1288
+ let provider = meta
1289
+ .get("provider")
1290
+ .and_then(Value::as_str)
1291
+ .unwrap_or("codex");
1292
+ let auth_mode = meta
1293
+ .get("auth_mode")
1294
+ .and_then(Value::as_str)
1295
+ .unwrap_or("subscription");
1296
+ let role = meta
1297
+ .get("role")
1298
+ .and_then(Value::as_str)
1299
+ .unwrap_or_else(|| agent_id.as_str());
1300
+ let mut entry = serde_json::json!({
1301
+ "provider": provider,
1302
+ "auth_mode": auth_mode,
1303
+ "role": role,
1304
+ "status": "running",
1305
+ "dynamic_role_file": dynamic_role_file.to_string_lossy().to_string(),
1306
+ });
1307
+ if let Some(model) = meta.get("model").and_then(Value::as_str) {
1308
+ if let Some(obj) = entry.as_object_mut() {
1309
+ obj.insert("model".to_string(), serde_json::json!(model));
1310
+ }
1311
+ }
1312
+ agent_map.insert(agent_id.as_str().to_string(), entry);
1313
+ save_runtime_state(workspace, &state).map_err(|e| LifecycleError::StatePersist(e.to_string()))
1314
+ }
1315
+
1316
+ fn materialize_added_role_file(
1317
+ team_dir: &Path,
1318
+ agent_id: &AgentId,
1319
+ role_file_path: &Path,
1320
+ ) -> Result<PathBuf, LifecycleError> {
1321
+ let agents_dir = team_dir.join("agents");
1322
+ std::fs::create_dir_all(&agents_dir)
1323
+ .map_err(|e| LifecycleError::StatePersist(format!("create agents dir: {e}")))?;
1324
+ let target = agents_dir.join(format!("{}.md", agent_id.as_str()));
1325
+ if role_file_path == target {
1326
+ return Ok(target);
1327
+ }
1328
+ std::fs::copy(role_file_path, &target).map_err(|e| {
1329
+ LifecycleError::StatePersist(format!(
1330
+ "copy role file {} -> {}: {e}",
1331
+ role_file_path.display(),
1332
+ target.display()
1333
+ ))
1334
+ })?;
1335
+ Ok(target)
1336
+ }
1337
+
1338
+ /// `fork_agent(workspace, source_agent_id, as_agent_id, ...)`(`lifecycle/operations.py:284`)。
1339
+ /// native session fork(provider 须 supports_session_fork ∧ auth_mode!=compatible_api);
1340
+ /// 失败回滚,每条失败臂 `adapter.cleanup_mcp`。
1341
+ pub fn fork_agent(
1342
+ workspace: &Path,
1343
+ source_agent_id: &AgentId,
1344
+ as_agent_id: &AgentId,
1345
+ open_display: bool,
1346
+ team: Option<&str>,
1347
+ ) -> Result<ForkAgentReport, LifecycleError> {
1348
+ let selected = crate::state::selector::resolve_active_team(
1349
+ workspace,
1350
+ team,
1351
+ crate::state::selector::SelectorMode::RequireSpec,
1352
+ )
1353
+ .map_err(|e| LifecycleError::TeamSelect(e.to_string()))?;
1354
+ fork_agent_with_transport(
1355
+ workspace,
1356
+ source_agent_id,
1357
+ as_agent_id,
1358
+ open_display,
1359
+ team,
1360
+ &crate::tmux_backend::TmuxBackend::for_workspace(&selected.run_workspace),
1361
+ )
1362
+ }
1363
+
1364
+ pub fn fork_agent_with_transport(
1365
+ workspace: &Path,
1366
+ source_agent_id: &AgentId,
1367
+ as_agent_id: &AgentId,
1368
+ open_display: bool,
1369
+ team: Option<&str>,
1370
+ transport: &dyn Transport,
1371
+ ) -> Result<ForkAgentReport, LifecycleError> {
1372
+ let _ = open_display;
1373
+ let selected = crate::state::selector::resolve_active_team(
1374
+ workspace,
1375
+ team,
1376
+ crate::state::selector::SelectorMode::RequireSpec,
1377
+ )
1378
+ .map_err(|e| LifecycleError::TeamSelect(e.to_string()))?;
1379
+ let spec_workspace = selected
1380
+ .spec_workspace
1381
+ .ok_or_else(|| LifecycleError::TeamSelect("active team spec workspace not found".to_string()))?;
1382
+ let workspace = selected.run_workspace;
1383
+ let state = selected.state;
1384
+ ensure_owner_allowed_for_state(&state, Some(source_agent_id))?;
1385
+ let spec_path = spec_workspace.join("team.spec.yaml");
1386
+ let text = std::fs::read_to_string(&spec_path)
1387
+ .map_err(|e| LifecycleError::Compile(format!("{}: {e}", spec_path.display())))?;
1388
+ let spec = yaml::loads(&text).map_err(|e| LifecycleError::Compile(e.to_string()))?;
1389
+ if find_spec_agent(&spec, as_agent_id).is_some() || leader_id_matches(&spec, as_agent_id) {
1390
+ return Err(LifecycleError::RequirementUnmet(format!(
1391
+ "agent id already exists: {as_agent_id}"
1392
+ )));
1393
+ }
1394
+ let source_agent = find_spec_agent(&spec, source_agent_id)
1395
+ .ok_or_else(|| LifecycleError::RequirementUnmet(format!("unknown worker agent id: {source_agent_id}")))?;
1396
+ let session_id = state
1397
+ .get("agents")
1398
+ .and_then(|v| v.get(source_agent_id.as_str()))
1399
+ .and_then(|v| v.get("session_id"))
1400
+ .and_then(|v| v.as_str())
1401
+ .filter(|s| !s.is_empty())
1402
+ .map(crate::provider::SessionId::new)
1403
+ .ok_or_else(|| {
1404
+ LifecycleError::Provider(format!(
1405
+ "cannot fork {source_agent_id}: source session_id is missing"
1406
+ ))
1407
+ })?;
1408
+ let session_name = state
1409
+ .get("session_name")
1410
+ .and_then(|v| v.as_str())
1411
+ .filter(|s| !s.is_empty())
1412
+ .map(SessionName::new)
1413
+ .unwrap_or_else(|| spec_session_name(&spec));
1414
+ if transport
1415
+ .list_windows(&session_name)
1416
+ .map(|windows| windows.iter().any(|w| w.as_str() == as_agent_id.as_str()))
1417
+ .unwrap_or(false)
1418
+ {
1419
+ return Err(LifecycleError::Transport(format!(
1420
+ "tmux window already exists for fork target: {}:{}",
1421
+ session_name.as_str(),
1422
+ as_agent_id.as_str()
1423
+ )));
1424
+ }
1425
+ let new_spec = append_forked_agent(&spec, source_agent, source_agent_id, as_agent_id)?;
1426
+ crate::model::spec::validate_spec(&new_spec, &spec_workspace)
1427
+ .map_err(|e| LifecycleError::Compile(e.to_string()))?;
1428
+ std::fs::write(&spec_path, yaml::dumps(&new_spec))
1429
+ .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", spec_path.display())))?;
1430
+ let new_agent = find_spec_agent(&new_spec, as_agent_id)
1431
+ .ok_or_else(|| LifecycleError::RequirementUnmet(format!("unknown worker agent id: {as_agent_id}")))?;
1432
+ let provider = new_agent
1433
+ .get("provider")
1434
+ .and_then(Value::as_str)
1435
+ .and_then(parse_provider)
1436
+ .unwrap_or(Provider::Codex);
1437
+ let auth_mode = new_agent
1438
+ .get("auth_mode")
1439
+ .and_then(Value::as_str)
1440
+ .and_then(parse_auth_mode)
1441
+ .unwrap_or(AuthMode::Subscription);
1442
+ let adapter = crate::provider::get_adapter(provider);
1443
+ let provider_str = new_agent
1444
+ .get("provider")
1445
+ .and_then(Value::as_str)
1446
+ .unwrap_or("codex");
1447
+ if auth_mode == AuthMode::CompatibleApi || !adapter.caps().fork {
1448
+ let _ = std::fs::write(&spec_path, text.as_bytes());
1449
+ return Err(LifecycleError::Provider(format!(
1450
+ "{provider_str} does not support native session fork"
1451
+ )));
1452
+ }
1453
+ let role = new_agent.get("role").and_then(Value::as_str);
1454
+ let model = new_agent.get("model").and_then(Value::as_str);
1455
+ let safety = effective_runtime_config(&new_spec)?;
1456
+ let tools = worker_tool_refs(agent_tool_strings(new_agent), &safety);
1457
+ let tool_refs: Vec<&str> = tools.iter().map(String::as_str).collect();
1458
+ let mcp_config = adapter
1459
+ .mcp_config(auth_mode)
1460
+ .map_err(|e| {
1461
+ let _ = std::fs::write(&spec_path, text.as_bytes());
1462
+ LifecycleError::Provider(e.to_string())
1463
+ })?;
1464
+ let mut argv = adapter
1465
+ .fork_with_context(
1466
+ Some(&session_id),
1467
+ auth_mode,
1468
+ Some(&mcp_config),
1469
+ role,
1470
+ model,
1471
+ &tool_refs,
1472
+ )
1473
+ .map_err(|e| {
1474
+ let _ = std::fs::write(&spec_path, text.as_bytes());
1475
+ LifecycleError::Provider(e.to_string())
1476
+ })?;
1477
+ let fork_team = crate::messaging::leader_receiver::active_team_key(&workspace, &state);
1478
+ fill_spawn_placeholders_full(&mut argv, &workspace, as_agent_id.as_str(), Some(&fork_team));
1479
+ let window = WindowName::new(as_agent_id.as_str());
1480
+ // fork inherits the parent agent's owner team via runtime state (`active_team_key`).
1481
+ let env = inherited_env_with_team_overrides(
1482
+ &workspace,
1483
+ as_agent_id.as_str(),
1484
+ Some(&fork_team),
1485
+ );
1486
+ // golden operations.py:336 -> _tmux_start_command_for_agent_window (runtime.py:1017-1020): branch on
1487
+ // _tmux_session_exists — an ABSENT session => new-session (spawn_first), present => new-window
1488
+ // (spawn_into). The Rust restart seam (restart.rs spawn_agent_window) uses the same branch.
1489
+ let session_live = transport.has_session(&session_name).unwrap_or(false);
1490
+ let spawn_result = if session_live {
1491
+ transport.spawn_into(&session_name, &window, &argv, &workspace, &env)
1492
+ } else {
1493
+ transport.spawn_first(&session_name, &window, &argv, &workspace, &env)
1494
+ };
1495
+ let _spawn = spawn_result.map_err(|e| {
1496
+ let _ = std::fs::write(&spec_path, text.as_bytes());
1497
+ LifecycleError::Transport(e.to_string())
1498
+ })?;
1499
+ let old_state = state.clone();
1500
+ let mut next_state = state;
1501
+ upsert_forked_agent_state(&mut next_state, source_agent_id, as_agent_id, new_agent)?;
1502
+ if let Err(e) = save_runtime_state(&workspace, &next_state) {
1503
+ rollback_fork_after_spawn(&workspace, &spec_path, &text, &old_state, transport, &session_name, &window);
1504
+ return Err(LifecycleError::StatePersist(e.to_string()));
1505
+ }
1506
+ let coordinator_started =
1507
+ crate::coordinator::start_coordinator(&crate::coordinator::WorkspacePath::new(
1508
+ workspace.to_path_buf(),
1509
+ ))
1510
+ .map(|report| report.ok)
1511
+ .map_err(|e| {
1512
+ rollback_fork_after_spawn(&workspace, &spec_path, &text, &old_state, transport, &session_name, &window);
1513
+ LifecycleError::StatePersist(e.to_string())
1514
+ })?;
1515
+ Ok(ForkAgentReport {
1516
+ source_agent_id: source_agent_id.clone(),
1517
+ new_agent_id: as_agent_id.clone(),
1518
+ env: AgentActionEnvelope {
1519
+ agent_id: as_agent_id.clone(),
1520
+ state_file: crate::state::persist::runtime_state_path(&workspace),
1521
+ coordinator_started,
1522
+ },
1523
+ session_id: None,
1524
+ })
1525
+ }
1526
+
1527
+ fn rollback_fork_after_spawn(
1528
+ workspace: &Path,
1529
+ spec_path: &Path,
1530
+ spec_text: &str,
1531
+ old_state: &serde_json::Value,
1532
+ transport: &dyn Transport,
1533
+ session_name: &SessionName,
1534
+ window: &WindowName,
1535
+ ) {
1536
+ let _ = transport.kill_window(&Target::SessionWindow {
1537
+ session: session_name.clone(),
1538
+ window: window.clone(),
1539
+ });
1540
+ let _ = std::fs::write(spec_path, spec_text.as_bytes());
1541
+ let _ = save_runtime_state(workspace, old_state);
1542
+ }
1543
+
1544
+ fn leader_id_matches(spec: &Value, agent_id: &AgentId) -> bool {
1545
+ spec.get("leader")
1546
+ .and_then(|v| v.get("id"))
1547
+ .and_then(Value::as_str)
1548
+ .map(|id| id == agent_id.as_str())
1549
+ .unwrap_or(false)
1550
+ }
1551
+
1552
+ fn find_spec_agent<'a>(spec: &'a Value, agent_id: &AgentId) -> Option<&'a Value> {
1553
+ let leader_is_agent = spec
1554
+ .get("leader")
1555
+ .and_then(|v| v.get("id"))
1556
+ .and_then(Value::as_str)
1557
+ .map(|id| id == agent_id.as_str())
1558
+ .unwrap_or(false);
1559
+ if leader_is_agent {
1560
+ return None;
1561
+ }
1562
+ spec.get("agents")?
1563
+ .as_list()?
1564
+ .iter()
1565
+ .find(|agent| {
1566
+ agent
1567
+ .get("id")
1568
+ .and_then(Value::as_str)
1569
+ .map(|id| id == agent_id.as_str())
1570
+ .unwrap_or(false)
1571
+ })
1572
+ }
1573
+
1574
+ fn append_forked_agent(
1575
+ spec: &Value,
1576
+ source_agent: &Value,
1577
+ source_agent_id: &AgentId,
1578
+ as_agent_id: &AgentId,
1579
+ ) -> Result<Value, LifecycleError> {
1580
+ let mut new_agent = source_agent.clone();
1581
+ set_yaml_map_value(
1582
+ &mut new_agent,
1583
+ "id",
1584
+ Value::Str(as_agent_id.as_str().to_string()),
1585
+ )?;
1586
+ // golden operations.py:315 `str(label or new_agent.get("role") or as_agent_id)` — Python `or`
1587
+ // falsiness: an EMPTY-string role is falsy and falls through to as_agent_id.
1588
+ let role = new_agent
1589
+ .get("role")
1590
+ .and_then(Value::as_str)
1591
+ .filter(|s| !s.is_empty())
1592
+ .unwrap_or_else(|| as_agent_id.as_str())
1593
+ .to_string();
1594
+ set_yaml_map_value(&mut new_agent, "role", Value::Str(role.clone()))?;
1595
+ set_yaml_map_value(
1596
+ &mut new_agent,
1597
+ "forked_from",
1598
+ Value::Str(source_agent_id.as_str().to_string()),
1599
+ )?;
1600
+ set_yaml_map_value(
1601
+ &mut new_agent,
1602
+ "preferred_for",
1603
+ Value::List(vec![
1604
+ Value::Str(as_agent_id.as_str().to_string()),
1605
+ Value::Str(role),
1606
+ ]),
1607
+ )?;
1608
+
1609
+ let Value::Map(pairs) = spec else {
1610
+ return Err(LifecycleError::Compile("spec root is not a map".to_string()));
1611
+ };
1612
+ let mut out = Vec::new();
1613
+ for (key, value) in pairs {
1614
+ if key == "agents" {
1615
+ let mut agents = value.as_list().map(|items| items.to_vec()).unwrap_or_default();
1616
+ agents.push(new_agent.clone());
1617
+ out.push((key.clone(), Value::List(agents)));
1618
+ } else if key == "runtime" {
1619
+ out.push((key.clone(), runtime_with_startup_agent(value, as_agent_id)));
1620
+ } else {
1621
+ out.push((key.clone(), value.clone()));
1622
+ }
1623
+ }
1624
+ Ok(Value::Map(out))
1625
+ }
1626
+
1627
+ fn set_yaml_map_value(value: &mut Value, key: &str, next: Value) -> Result<(), LifecycleError> {
1628
+ let Value::Map(pairs) = value else {
1629
+ return Err(LifecycleError::Compile("agent entry is not a map".to_string()));
1630
+ };
1631
+ if let Some((_, existing)) = pairs.iter_mut().find(|(k, _)| k == key) {
1632
+ *existing = next;
1633
+ } else {
1634
+ pairs.push((key.to_string(), next));
1635
+ }
1636
+ Ok(())
1637
+ }
1638
+
1639
+ fn runtime_with_startup_agent(runtime: &Value, agent_id: &AgentId) -> Value {
1640
+ let Value::Map(pairs) = runtime else {
1641
+ return runtime.clone();
1642
+ };
1643
+ let mut out = Vec::new();
1644
+ let mut saw_startup = false;
1645
+ for (key, value) in pairs {
1646
+ if key == "startup_order" {
1647
+ saw_startup = true;
1648
+ let mut order = value.as_list().map(|items| items.to_vec()).unwrap_or_default();
1649
+ let already_present = order
1650
+ .iter()
1651
+ .any(|item| item.as_str().map(|id| id == agent_id.as_str()).unwrap_or(false));
1652
+ if !already_present {
1653
+ order.push(Value::Str(agent_id.as_str().to_string()));
1654
+ }
1655
+ out.push((key.clone(), Value::List(order)));
1656
+ } else {
1657
+ out.push((key.clone(), value.clone()));
1658
+ }
1659
+ }
1660
+ if !saw_startup {
1661
+ out.push((
1662
+ "startup_order".to_string(),
1663
+ Value::List(vec![Value::Str(agent_id.as_str().to_string())]),
1664
+ ));
1665
+ }
1666
+ Value::Map(out)
1667
+ }
1668
+
1669
+ fn upsert_forked_agent_state(
1670
+ state: &mut serde_json::Value,
1671
+ source_agent_id: &AgentId,
1672
+ as_agent_id: &AgentId,
1673
+ spec_agent: &Value,
1674
+ ) -> Result<(), LifecycleError> {
1675
+ if !state.is_object() {
1676
+ *state = serde_json::json!({});
1677
+ }
1678
+ let Some(root) = state.as_object_mut() else {
1679
+ return Err(LifecycleError::StatePersist(
1680
+ "runtime state root is not an object".to_string(),
1681
+ ));
1682
+ };
1683
+ let agents = root
1684
+ .entry("agents".to_string())
1685
+ .or_insert_with(|| serde_json::json!({}));
1686
+ if !agents.is_object() {
1687
+ *agents = serde_json::json!({});
1688
+ }
1689
+ let Some(agent_map) = agents.as_object_mut() else {
1690
+ return Err(LifecycleError::StatePersist(
1691
+ "runtime state agents is not an object".to_string(),
1692
+ ));
1693
+ };
1694
+ let provider = spec_agent
1695
+ .get("provider")
1696
+ .and_then(Value::as_str)
1697
+ .unwrap_or("codex");
1698
+ agent_map.insert(
1699
+ as_agent_id.as_str().to_string(),
1700
+ serde_json::json!({
1701
+ "status": "running",
1702
+ "provider": provider,
1703
+ "window": as_agent_id.as_str(),
1704
+ "forked_from": source_agent_id.as_str(),
1705
+ }),
1706
+ );
1707
+ Ok(())
1708
+ }
1709
+
1710
+ pub(crate) fn ensure_owner_allowed(workspace: &Path) -> Result<(), LifecycleError> {
1711
+ let state = crate::state::persist::load_runtime_state(workspace)
1712
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
1713
+ ensure_owner_allowed_for_state(&state, None)
1714
+ }
1715
+
1716
+ pub(crate) fn ensure_owner_allowed_for_state(
1717
+ state: &serde_json::Value,
1718
+ target_role: Option<&AgentId>,
1719
+ ) -> Result<(), LifecycleError> {
1720
+ struct NoopLiveness;
1721
+ impl crate::state::owner_gate::PaneLivenessProbe for NoopLiveness {
1722
+ fn liveness(&self, _pane_id: &str) -> crate::model::enums::PaneLiveness {
1723
+ crate::model::enums::PaneLiveness::Live
1724
+ }
1725
+ }
1726
+
1727
+ let target_team = crate::state::projection::team_state_key(state);
1728
+ if caller_is_target_role_in_team(&target_team, target_role) {
1729
+ return Ok(());
1730
+ }
1731
+ let caller = crate::state::identity::caller_identity_from_env(
1732
+ Some(state),
1733
+ &crate::state::identity::SystemEnv,
1734
+ Some(&target_team),
1735
+ None,
1736
+ )
1737
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
1738
+ if let Some(refusal) = crate::state::owner_gate::check_team_owner(
1739
+ state,
1740
+ &caller,
1741
+ false,
1742
+ &NoopLiveness,
1743
+ ) {
1744
+ return Err(LifecycleError::OwnerRefused(refusal.to_string()));
1745
+ }
1746
+ Ok(())
1747
+ }
1748
+
1749
+ fn caller_is_target_role_in_team(target_team: &str, target_role: Option<&AgentId>) -> bool {
1750
+ let Some(target_role) = target_role else {
1751
+ return false;
1752
+ };
1753
+ std::env::var("TEAM_AGENT_ID").ok().as_deref() == Some(target_role.as_str())
1754
+ && std::env::var("TEAM_AGENT_TEAM_ID").ok().as_deref() == Some(target_team)
1755
+ }
1756
+
1757
+ pub(crate) fn state_path(workspace: &Path) -> std::path::PathBuf {
1758
+ crate::state::persist::runtime_state_path(workspace)
1759
+ }
1760
+
1761
+ fn initial_runtime_state(
1762
+ spec: &Value,
1763
+ spec_path: &Path,
1764
+ workspace: &Path,
1765
+ team_dir: &Path,
1766
+ ) -> serde_json::Value {
1767
+ let mut agents = serde_json::Map::new();
1768
+ for agent in spec_agent_values(spec) {
1769
+ let Some(id) = agent.get("id").and_then(Value::as_str) else {
1770
+ continue;
1771
+ };
1772
+ let provider = agent.get("provider").and_then(Value::as_str).unwrap_or("codex");
1773
+ let role = agent.get("role").and_then(Value::as_str).unwrap_or(id);
1774
+ let model = agent.get("model").and_then(Value::as_str);
1775
+ let auth_mode = agent.get("auth_mode").and_then(Value::as_str);
1776
+ let mut value = serde_json::json!({
1777
+ "provider": provider,
1778
+ "role": role,
1779
+ });
1780
+ if let Some(obj) = value.as_object_mut() {
1781
+ if let Some(model) = model {
1782
+ obj.insert("model".to_string(), serde_json::json!(model));
1783
+ }
1784
+ if let Some(auth_mode) = auth_mode {
1785
+ obj.insert("auth_mode".to_string(), serde_json::json!(auth_mode));
1786
+ }
1787
+ }
1788
+ agents.insert(id.to_string(), value);
1789
+ }
1790
+ let requested_display = spec
1791
+ .get("runtime")
1792
+ .and_then(|runtime| runtime.get("display_backend"))
1793
+ .and_then(Value::as_str)
1794
+ .and_then(|backend| serde_json::from_value::<DisplayBackend>(serde_json::json!(backend)).ok());
1795
+ let display_backend =
1796
+ crate::lifecycle::display::resolve_display_backend(requested_display, None).backend;
1797
+ let mut state = serde_json::Map::new();
1798
+ state.insert(
1799
+ "spec_path".to_string(),
1800
+ serde_json::json!(spec_path.to_string_lossy().to_string()),
1801
+ );
1802
+ state.insert(
1803
+ "workspace".to_string(),
1804
+ serde_json::json!(workspace.to_string_lossy().to_string()),
1805
+ );
1806
+ state.insert(
1807
+ "team_dir".to_string(),
1808
+ serde_json::json!(team_dir.to_string_lossy().to_string()),
1809
+ );
1810
+ state.insert(
1811
+ "session_name".to_string(),
1812
+ serde_json::json!(spec_session_name(spec).as_str()),
1813
+ );
1814
+ state.insert(
1815
+ "leader".to_string(),
1816
+ spec.get("leader").map(yaml_value_to_json).unwrap_or(serde_json::Value::Null),
1817
+ );
1818
+ state.insert("agents".to_string(), serde_json::Value::Object(agents));
1819
+ state.insert("tasks".to_string(), spec_tasks_json(spec));
1820
+ state.insert("display_backend".to_string(), serde_json::json!(display_backend));
1821
+ let mut state = serde_json::Value::Object(state);
1822
+ if !seed_launched_owner_from_env(&mut state) {
1823
+ let team_id = crate::state::projection::team_state_key(&state);
1824
+ seed_unbound_launched_owner(&mut state, &team_id);
1825
+ }
1826
+ state
1827
+ }
1828
+
1829
+ fn seed_launched_owner_from_env(state: &mut serde_json::Value) -> bool {
1830
+ let team_id = crate::state::projection::team_state_key(state);
1831
+ let Ok(caller) = crate::state::identity::caller_identity_from_env(
1832
+ Some(state),
1833
+ &crate::state::identity::SystemEnv,
1834
+ Some(&team_id),
1835
+ None,
1836
+ ) else {
1837
+ return false;
1838
+ };
1839
+ let provider = if caller.provider.is_empty() {
1840
+ "codex".to_string()
1841
+ } else {
1842
+ caller.provider
1843
+ };
1844
+ let pane_id = caller.pane_id;
1845
+ if pane_id.is_empty() {
1846
+ return false;
1847
+ }
1848
+ let owner_epoch = 1u64;
1849
+ let owner = serde_json::json!({
1850
+ "pane_id": pane_id,
1851
+ "provider": provider.clone(),
1852
+ "machine_fingerprint": caller.machine_fingerprint,
1853
+ "leader_session_uuid": caller.leader_session_uuid,
1854
+ "owner_epoch": owner_epoch,
1855
+ "claimed_at": spawn_timestamp(),
1856
+ "claimed_via": "quick-start",
1857
+ "os_user": std::env::var("USER")
1858
+ .or_else(|_| std::env::var("USERNAME"))
1859
+ .unwrap_or_default(),
1860
+ });
1861
+ let receiver = serde_json::json!({
1862
+ "mode": "direct_tmux",
1863
+ "status": "attached",
1864
+ "provider": provider,
1865
+ "pane_id": owner.get("pane_id").cloned().unwrap_or(serde_json::Value::Null),
1866
+ "leader_session_uuid": owner.get("leader_session_uuid").cloned().unwrap_or(serde_json::Value::Null),
1867
+ "owner_epoch": owner_epoch,
1868
+ "discovery": "quick_start",
1869
+ });
1870
+ let mut receiver = receiver;
1871
+ if let (Some(receiver), Some(socket)) = (
1872
+ receiver.as_object_mut(),
1873
+ crate::tmux_backend::socket_name_from_tmux_env(),
1874
+ ) {
1875
+ receiver.insert("tmux_socket".to_string(), serde_json::json!(socket));
1876
+ }
1877
+ if let Some(obj) = state.as_object_mut() {
1878
+ obj.insert("leader_receiver".to_string(), receiver);
1879
+ obj.insert("team_owner".to_string(), owner);
1880
+ obj.insert("owner_epoch".to_string(), serde_json::json!(owner_epoch));
1881
+ }
1882
+ true
1883
+ }
1884
+
1885
+ fn spec_tasks_json(spec: &Value) -> serde_json::Value {
1886
+ spec.get("tasks")
1887
+ .and_then(Value::as_list)
1888
+ .map(|tasks| {
1889
+ serde_json::Value::Array(tasks.iter().map(yaml_value_to_json).collect())
1890
+ })
1891
+ .unwrap_or_else(|| serde_json::json!([]))
1892
+ }
1893
+
1894
+ fn yaml_value_to_json(value: &Value) -> serde_json::Value {
1895
+ match value {
1896
+ Value::Null => serde_json::Value::Null,
1897
+ Value::Bool(v) => serde_json::json!(v),
1898
+ Value::Int(v) => serde_json::json!(v),
1899
+ Value::Float(v) => serde_json::json!(v),
1900
+ Value::Str(v) => serde_json::json!(v),
1901
+ Value::List(values) => {
1902
+ serde_json::Value::Array(values.iter().map(yaml_value_to_json).collect())
1903
+ }
1904
+ Value::Map(entries) => {
1905
+ let mut out = serde_json::Map::new();
1906
+ for (key, item) in entries {
1907
+ out.insert(key.clone(), yaml_value_to_json(item));
1908
+ }
1909
+ serde_json::Value::Object(out)
1910
+ }
1911
+ }
1912
+ }
1913
+
1914
+ /// Set `runtime.session_name` on the compiled spec to `session_name`, creating the
1915
+ /// `runtime` map and/or the `session_name` entry if absent. Used by quick-start to
1916
+ /// derive the tmux session from the REQUESTED team identity (CR-040/042) rather
1917
+ /// than the template's compiled-in name.
1918
+ fn override_spec_session_name(spec: &mut Value, session_name: &str) {
1919
+ let Value::Map(root) = spec else { return };
1920
+ let runtime_slot = root
1921
+ .iter_mut()
1922
+ .find(|(k, _)| k == "runtime")
1923
+ .map(|(_, v)| v);
1924
+ match runtime_slot {
1925
+ Some(Value::Map(runtime)) => {
1926
+ if let Some((_, existing)) = runtime.iter_mut().find(|(k, _)| k == "session_name") {
1927
+ *existing = Value::Str(session_name.to_string());
1928
+ } else {
1929
+ runtime.push(("session_name".to_string(), Value::Str(session_name.to_string())));
1930
+ }
1931
+ }
1932
+ Some(other) => {
1933
+ *other = Value::Map(vec![(
1934
+ "session_name".to_string(),
1935
+ Value::Str(session_name.to_string()),
1936
+ )]);
1937
+ }
1938
+ None => {
1939
+ root.push((
1940
+ "runtime".to_string(),
1941
+ Value::Map(vec![(
1942
+ "session_name".to_string(),
1943
+ Value::Str(session_name.to_string()),
1944
+ )]),
1945
+ ));
1946
+ }
1947
+ }
1948
+ }
1949
+
1950
+ fn spec_session_name(spec: &Value) -> SessionName {
1951
+ let name = spec
1952
+ .get("runtime")
1953
+ .and_then(|v| v.get("session_name"))
1954
+ .and_then(Value::as_str)
1955
+ .unwrap_or("team-agent");
1956
+ SessionName::new(name)
1957
+ }
1958
+
1959
+ fn spec_agents(spec: &Value) -> Vec<AgentId> {
1960
+ spec_agent_values(spec)
1961
+ .into_iter()
1962
+ .filter_map(|agent| agent.get("id").and_then(Value::as_str).map(AgentId::new))
1963
+ .collect()
1964
+ }
1965
+
1966
+ fn spec_agent_values(spec: &Value) -> Vec<&Value> {
1967
+ spec.get("agents")
1968
+ .and_then(Value::as_list)
1969
+ .map(|agents| agents.iter().collect())
1970
+ .unwrap_or_default()
1971
+ }
1972
+
1973
+ fn spec_routes(spec: &Value) -> Vec<RoutingDecision> {
1974
+ spec.get("tasks")
1975
+ .and_then(Value::as_list)
1976
+ .map(|tasks| {
1977
+ tasks
1978
+ .iter()
1979
+ .map(|task| {
1980
+ let routed = crate::model::routing::route_task(spec, task);
1981
+ RoutingDecision {
1982
+ task_id: task.get("id").and_then(Value::as_str).map(str::to_string),
1983
+ selected_agent: routed.agent_id,
1984
+ reason: routed.reason,
1985
+ manual_override: false,
1986
+ }
1987
+ })
1988
+ .collect()
1989
+ })
1990
+ .unwrap_or_default()
1991
+ }
1992
+
1993
+ fn spec_default_assignee(spec: &Value) -> Option<AgentId> {
1994
+ spec.get("routing")
1995
+ .and_then(|v| v.get("default_assignee"))
1996
+ .and_then(Value::as_str)
1997
+ .map(AgentId::new)
1998
+ .or_else(|| spec_agents(spec).into_iter().next())
1999
+ }
2000
+
2001
+ pub(crate) fn effective_runtime_config(spec: &Value) -> Result<DangerousApproval, LifecycleError> {
2002
+ let enabled = spec
2003
+ .get("runtime")
2004
+ .and_then(|v| v.get("dangerous_auto_approve"))
2005
+ .is_some_and(Value::is_truthy);
2006
+ if enabled {
2007
+ let leader = detect_dangerous_approval()?;
2008
+ Ok(DangerousApproval {
2009
+ enabled: true,
2010
+ source: DangerousApprovalSource::RuntimeConfig,
2011
+ inherited: false,
2012
+ provider: None,
2013
+ flag: None,
2014
+ worker_capability_above_leader: !leader.enabled,
2015
+ ancestry_binary_name: leader.ancestry_binary_name,
2016
+ unexpected_binary: false,
2017
+ })
2018
+ } else {
2019
+ Ok(detect_dangerous_approval()?)
2020
+ }
2021
+ }
2022
+
2023
+ fn disabled_dangerous_approval() -> DangerousApproval {
2024
+ DangerousApproval {
2025
+ enabled: false,
2026
+ source: DangerousApprovalSource::Disabled,
2027
+ inherited: false,
2028
+ provider: None,
2029
+ flag: None,
2030
+ worker_capability_above_leader: false,
2031
+ ancestry_binary_name: None,
2032
+ unexpected_binary: false,
2033
+ }
2034
+ }
2035
+
2036
+ pub(crate) fn effective_runtime_config_for_worker_spawn() -> Result<DangerousApproval, LifecycleError> {
2037
+ detect_dangerous_approval()
2038
+ }
2039
+
2040
+ pub(crate) fn worker_tool_refs(
2041
+ mut tools: Vec<String>,
2042
+ safety: &DangerousApproval,
2043
+ ) -> Vec<String> {
2044
+ if safety.enabled && !tools.iter().any(|tool| tool == "dangerous_auto_approve") {
2045
+ tools.push("dangerous_auto_approve".to_string());
2046
+ }
2047
+ tools
2048
+ }
2049
+
2050
+ fn write_launch_permission_audit(
2051
+ workspace: &Path,
2052
+ safety: &DangerousApproval,
2053
+ ) -> Result<(), LifecycleError> {
2054
+ crate::event_log::EventLog::new(workspace)
2055
+ .write(
2056
+ "launch.permissions_resolved",
2057
+ serde_json::json!({
2058
+ "dangerous_auto_approve": safety.enabled,
2059
+ "dangerous_auto_approve_source": safety.source,
2060
+ "dangerous_auto_approve_inherited": safety.inherited,
2061
+ "dangerous_auto_approve_provider": safety.provider,
2062
+ "dangerous_auto_approve_flag": safety.flag,
2063
+ "worker_capability_above_leader": safety.worker_capability_above_leader,
2064
+ "ancestry_binary_name": safety.ancestry_binary_name,
2065
+ }),
2066
+ )
2067
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
2068
+ if safety.unexpected_binary {
2069
+ crate::event_log::EventLog::new(workspace)
2070
+ .write(
2071
+ "dangerous_flag_in_unexpected_binary",
2072
+ serde_json::json!({
2073
+ "provider": safety.provider,
2074
+ "flag": safety.flag,
2075
+ "ancestry_binary_name": safety.ancestry_binary_name,
2076
+ }),
2077
+ )
2078
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
2079
+ }
2080
+ Ok(())
2081
+ }
2082
+
2083
+ fn team_workspace(team_dir: &Path) -> PathBuf {
2084
+ crate::model::paths::team_workspace(team_dir).unwrap_or_else(|_| {
2085
+ team_dir
2086
+ .parent()
2087
+ .map(Path::to_path_buf)
2088
+ .unwrap_or_else(|| team_dir.to_path_buf())
2089
+ })
2090
+ }
2091
+
2092
+ fn agent_id_exists_in_team_dir(team_dir: &Path, agent_id: &AgentId) -> bool {
2093
+ let spec_path = team_dir.join("team.spec.yaml");
2094
+ if let Ok(text) = std::fs::read_to_string(&spec_path) {
2095
+ if let Ok(spec) = yaml::loads(&text) {
2096
+ return spec_agents(&spec)
2097
+ .into_iter()
2098
+ .any(|existing| existing.as_str() == agent_id.as_str());
2099
+ }
2100
+ }
2101
+ team_dir
2102
+ .join("agents")
2103
+ .join(format!("{}.md", agent_id.as_str()))
2104
+ .exists()
2105
+ }
2106
+
2107
+
2108
+ mod plan;
2109
+ pub use plan::{handle_report_result, start_plan};