@team-agent/installer 0.2.11 → 0.3.0

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