@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
@@ -1,926 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import hashlib
4
- import os
5
- import re
6
- import signal
7
- import shlex
8
- import subprocess
9
- import sys
10
- import time
11
- from datetime import datetime, timezone
12
- from pathlib import Path
13
- from typing import Any
14
-
15
- from team_agent.events import EventLog
16
- from team_agent.state import apply_first_time_leader_binding, derive_leader_session_uuid, leader_env_exports, load_runtime_state, save_runtime_state, save_team_scoped_state, select_runtime_state, team_state_key, validate_leader_uuid_from_targets
17
-
18
-
19
- def attach_leader(workspace: Path, pane: str | None = None, provider: str = "codex") -> dict[str, Any]:
20
- from team_agent.message_store import MessageStore
21
- from team_agent.runtime import _attach_leader_to_state, _runtime_lock, ensure_workspace_dirs
22
- ensure_workspace_dirs(workspace)
23
- # MED1/MED3: attach is a lease mutation; hold the single lease mutex so the state
24
- # change + event emission + dual-state write happen in one critical section.
25
- with _runtime_lock(workspace, LEADER_OWNERSHIP_LOCK):
26
- state = load_runtime_state(workspace)
27
- event_log = EventLog(workspace)
28
- receiver, validation = _attach_leader_to_state(
29
- workspace,
30
- state,
31
- pane=pane,
32
- provider=provider,
33
- event_log=event_log,
34
- source="manual",
35
- )
36
- save_runtime_state(workspace, state)
37
- requeued = MessageStore(workspace).requeue_delivery_exhausted_watchers()
38
- if requeued:
39
- event_log.write(
40
- "leader_receiver.requeued_exhausted_watchers",
41
- watcher_ids=requeued,
42
- count=len(requeued),
43
- trigger="attach_leader",
44
- )
45
- for watcher_id in requeued:
46
- event_log.write(
47
- "result_watcher.requeued",
48
- watcher_id=watcher_id,
49
- trigger="attach_leader",
50
- new_pane_id=receiver.get("pane_id"),
51
- )
52
- return {
53
- "ok": True,
54
- "leader_receiver": receiver,
55
- "validation": validation,
56
- "requeued_exhausted_watchers": requeued,
57
- }
58
-
59
-
60
- def start_leader(
61
- provider: str,
62
- provider_args: list[str],
63
- workspace: Path,
64
- *,
65
- attach_existing: bool = False,
66
- confirm_attach: bool = False,
67
- attach_session: str | None = None,
68
- ) -> None:
69
- plan = leader_start_plan(provider, provider_args, workspace, attach_existing=attach_existing, confirm_attach=confirm_attach, attach_session=attach_session)
70
- if plan.get("leader_session_uuid_source") == "override":
71
- EventLog(workspace).write("leader_session_uuid.override", source="explicit-override", uuid_prefix=str(plan.get("leader_session_uuid") or "")[:12], team_id=plan.get("team_id"))
72
- if plan["mode"] == "new_tmux_session" and not sys.stdin.isatty():
73
- plan = dict(plan)
74
- argv = list(plan["argv"])
75
- argv.insert(2, "-d")
76
- plan["argv"] = argv
77
- plan["detached"] = True
78
- EventLog(workspace).write("leader.start", provider=provider, workspace=str(workspace), mode=plan["mode"], session_name=plan.get("session_name"), argv=_leader_plan_log_argv(plan), leader_session_uuid_source=plan.get("leader_session_uuid_source"), uuid_prefix=str(plan.get("leader_session_uuid") or "")[:12] or None)
79
- _run_leader_plan(plan, workspace)
80
-
81
-
82
- def leader_start_plan(
83
- provider: str,
84
- provider_args: list[str],
85
- workspace: Path,
86
- *,
87
- attach_existing: bool = False,
88
- confirm_attach: bool = False,
89
- attach_session: str | None = None,
90
- ) -> dict[str, Any]:
91
- from team_agent.runtime import (
92
- RuntimeError,
93
- _tmux_session_exists,
94
- ensure_workspace_dirs,
95
- get_adapter,
96
- shutil_which,
97
- )
98
- workspace = workspace.resolve()
99
- ensure_workspace_dirs(workspace)
100
- adapter = get_adapter(provider)
101
- if not adapter.is_installed():
102
- raise RuntimeError(f"Provider {provider} command {adapter.command_name!r} not found")
103
- argv = [adapter.command_name, *provider_args]
104
- identity = _leader_identity_context(workspace)
105
- leader_env = _leader_provider_env(provider, identity)
106
- if attach_session:
107
- if not confirm_attach:
108
- raise RuntimeError("--attach-session requires --confirm")
109
- return {"mode": "attach_existing", "provider": provider, "workspace": str(workspace), "session_name": attach_session, "argv": ["tmux", "attach-session", "-t", attach_session]}
110
- if os.environ.get("TMUX"):
111
- return {"mode": "exec_provider", "provider": provider, "workspace": str(workspace), "argv": argv, "env": {**os.environ, **leader_env}, **identity}
112
- if not shutil_which("tmux"):
113
- raise RuntimeError("tmux is not installed; install tmux 3.3+ or start the leader from an existing tmux pane")
114
- session_name = leader_session_name(provider, workspace)
115
- if _tmux_session_exists(session_name):
116
- return {"mode": "attach_existing", "provider": provider, "workspace": str(workspace), "session_name": session_name, "argv": ["tmux", "attach-session", "-t", session_name]}
117
- exports = " ".join(f"{key}={shlex.quote(value)}" for key, value in leader_env.items())
118
- if os.environ.get("PATH"):
119
- exports = f"{exports} PATH={shlex.quote(os.environ['PATH'])}"
120
- shell = f"cd {shlex.quote(str(workspace))} && export {exports} && exec {shlex.join(argv)}"
121
- tmux_args = ["tmux", "new-session", "-s", session_name, "-n", provider, "-c", str(workspace)]
122
- return {
123
- "mode": "new_tmux_session",
124
- "provider": provider,
125
- "workspace": str(workspace),
126
- "session_name": session_name,
127
- "argv": [*tmux_args, "sh", "-lc", shell],
128
- "leader_env": leader_env,
129
- **identity,
130
- "detached": False,
131
- }
132
-
133
-
134
- def _run_leader_plan(plan: dict[str, Any], workspace: Path) -> None:
135
- session_name = plan.get("session_name")
136
- proc: subprocess.Popen[Any] | None = None
137
- sigints = 0
138
-
139
- def stop_process_tree() -> None:
140
- if session_name and plan["mode"] == "new_tmux_session":
141
- subprocess.run(["tmux", "kill-session", "-t", str(session_name)], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
142
- if proc and proc.poll() is None:
143
- proc.terminate()
144
-
145
- def handle_sigint(signum: int, _frame: Any) -> None:
146
- nonlocal sigints
147
- sigints += 1
148
- if proc and proc.poll() is None:
149
- try:
150
- proc.send_signal(signum)
151
- except ProcessLookupError:
152
- pass
153
- if sigints >= 2:
154
- stop_process_tree()
155
-
156
- old_sigint = signal.signal(signal.SIGINT, handle_sigint)
157
- try:
158
- if plan["mode"] == "exec_provider":
159
- os.chdir(workspace)
160
- proc = subprocess.Popen(plan["argv"], env=plan.get("env"))
161
- if plan.get("detached") and session_name:
162
- proc.wait()
163
- while _tmux_session_exists_local(str(session_name)):
164
- time.sleep(0.2)
165
- else:
166
- proc.wait()
167
- finally:
168
- signal.signal(signal.SIGINT, old_sigint)
169
- _print_team_running_reminder(workspace)
170
- raise SystemExit(proc.returncode if proc else 1)
171
-
172
-
173
- def _tmux_session_exists_local(session_name: str) -> bool:
174
- proc = subprocess.run(["tmux", "has-session", "-t", session_name], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
175
- return proc.returncode == 0
176
-
177
-
178
- def _print_team_running_reminder(workspace: Path) -> None:
179
- state = load_runtime_state(workspace)
180
- team_name = state.get("session_name")
181
- if not team_name or not _tmux_session_exists_local(str(team_name)):
182
- return
183
- print(f"team {team_name} is still running; run team-agent shutdown to close it OR team-agent attach-leader to reconnect.")
184
-
185
-
186
- def leader_session_name(provider: str, workspace: Path) -> str:
187
- digest = hashlib.sha1(str(workspace.resolve()).encode("utf-8")).hexdigest()[:8]
188
- folder = re.sub(r"[^A-Za-z0-9_.-]", "_", workspace.name)[:48].strip("._-") or "workspace"
189
- return f"team-agent-leader-{provider}-{folder}-{digest}"
190
-
191
-
192
- def _leader_identity_context(workspace: Path, team: str | None = None, state: dict[str, Any] | None = None) -> dict[str, Any]:
193
- state = state or _load_identity_state(workspace, team)
194
- team_id = team_state_key(state)
195
- machine = _identity_machine_fingerprint(state)
196
- user = _identity_os_user()
197
- override = os.environ.get("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE") or ""
198
- leader_uuid = override or _state_leader_session_uuid(state) or derive_leader_session_uuid(
199
- machine,
200
- str(workspace.resolve()),
201
- user,
202
- team_id,
203
- )
204
- return {
205
- "leader_session_uuid": leader_uuid,
206
- "leader_session_uuid_source": "override" if override else "derived",
207
- "machine_fingerprint": machine,
208
- "workspace_abspath": str(workspace.resolve()),
209
- "os_user": user,
210
- "team_id": team_id,
211
- }
212
-
213
-
214
- def _load_identity_state(workspace: Path, team: str | None) -> dict[str, Any]:
215
- try:
216
- return select_runtime_state(workspace, team)
217
- except Exception:
218
- return load_runtime_state(workspace)
219
-
220
-
221
- def _identity_machine_fingerprint(state: dict[str, Any]) -> str:
222
- for record in (state.get("team_owner"), state.get("leader_receiver")):
223
- if isinstance(record, dict) and record.get("machine_fingerprint"):
224
- return str(record["machine_fingerprint"])
225
- return os.environ.get("TEAM_AGENT_MACHINE_FINGERPRINT") or os.uname().nodename
226
-
227
-
228
- def _identity_os_user() -> str:
229
- return os.environ.get("USER") or os.environ.get("USERNAME") or ""
230
-
231
-
232
- def _state_leader_session_uuid(state: dict[str, Any]) -> str:
233
- for record in (state.get("team_owner"), state.get("leader_receiver")):
234
- if isinstance(record, dict) and record.get("leader_session_uuid"):
235
- return str(record["leader_session_uuid"])
236
- return ""
237
-
238
-
239
- def _leader_provider_env(provider: str, identity: dict[str, Any]) -> dict[str, str]:
240
- return {
241
- "TEAM_AGENT_LEADER_PROVIDER": provider,
242
- "TEAM_AGENT_LEADER_SESSION_UUID": str(identity["leader_session_uuid"]),
243
- "TEAM_AGENT_MACHINE_FINGERPRINT": str(identity["machine_fingerprint"]),
244
- "TEAM_AGENT_WORKSPACE": str(identity["workspace_abspath"]),
245
- "TEAM_AGENT_TEAM_ID": str(identity["team_id"]),
246
- }
247
-
248
-
249
- def _leader_plan_log_argv(plan: dict[str, Any]) -> list[str]:
250
- uuid_value = str(plan.get("leader_session_uuid") or "")
251
- if not uuid_value:
252
- return plan["argv"]
253
- return [str(part).replace(uuid_value, f"{uuid_value[:12]}...") for part in plan["argv"]]
254
-
255
-
256
- def attach_leader_to_state(
257
- workspace: Path,
258
- state: dict[str, Any],
259
- pane: str | None,
260
- provider: str,
261
- event_log: EventLog,
262
- source: str,
263
- require_current: bool = False,
264
- ) -> tuple[dict[str, Any], dict[str, Any]]:
265
- from team_agent.runtime import (
266
- RuntimeError,
267
- _leader_command_provider,
268
- _resolve_leader_pane,
269
- _target_fingerprint,
270
- _validate_leader_receiver,
271
- core_list_targets,
272
- get_adapter,
273
- run_cmd,
274
- )
275
- get_adapter(provider)
276
- pane_info, discovery = _resolve_leader_pane(pane, provider, workspace=workspace, require_current=require_current)
277
- inferred_provider = _leader_command_provider(pane_info.get("pane_current_command", ""))
278
- receiver_provider = inferred_provider or provider
279
- identity = _leader_identity_context(workspace, state=state)
280
- if identity.get("leader_session_uuid_source") == "override":
281
- event_log.write("leader_session_uuid.override", source="explicit-override", uuid_prefix=str(identity.get("leader_session_uuid") or "")[:12], team_id=identity.get("team_id"))
282
- receiver = {
283
- "mode": "direct_tmux",
284
- "status": "attached",
285
- "provider": receiver_provider,
286
- "pane_id": pane_info["pane_id"],
287
- "session_name": pane_info["session_name"],
288
- "window_index": pane_info["window_index"],
289
- "window_name": pane_info["window_name"],
290
- "pane_index": pane_info["pane_index"],
291
- "pane_tty": pane_info["pane_tty"],
292
- "pane_current_command": pane_info["pane_current_command"],
293
- "fingerprint": _target_fingerprint(pane_info),
294
- "attached_at": datetime.now(timezone.utc).isoformat(),
295
- "discovery": discovery,
296
- }
297
- if not state.get("team_owner") and source in {"launch", "quick_start"}:
298
- validation = apply_first_time_leader_binding(workspace, state, receiver, pane_info, identity, source)
299
- if not validation["ok"]:
300
- event_log.write("leader_receiver.attach_failed", target=pane or pane_info.get("pane_id"), discovery=discovery, provider=provider, reason=validation["reason"], error=validation.get("error"), source=source, first_time=True, uuid_prefix=str(identity.get("leader_session_uuid") or "")[:12])
301
- raise RuntimeError(f"leader pane validation failed: {validation['reason']}")
302
- _set_tmux_leader_environment(receiver, identity, event_log, run_cmd)
303
- event_log.write("leader_receiver.attached", target=receiver["pane_id"], session_name=receiver["session_name"], window_index=receiver["window_index"], window_name=receiver["window_name"], pane_index=receiver["pane_index"], pane_tty=receiver["pane_tty"], pane_current_command=receiver["pane_current_command"], provider=receiver_provider, requested_provider=provider if receiver_provider != provider else None, discovery=discovery, source=source, first_time=True, uuid_prefix=str(identity.get("leader_session_uuid") or "")[:12], leader_session_uuid_source=identity.get("leader_session_uuid_source"))
304
- return receiver, validation
305
- owner_record = state.get("team_owner") if isinstance(state.get("team_owner"), dict) else {}
306
- if receiver_provider != "fake":
307
- # C10/C12: carry the recorded owner's identity rather than re-deriving one
308
- # that can drift under symlinked/worktree paths.
309
- receiver["leader_session_uuid"] = str(owner_record.get("leader_session_uuid") or identity["leader_session_uuid"])
310
- if receiver_provider != provider:
311
- receiver["requested_provider"] = provider
312
- targets = core_list_targets()
313
- validation = validate_leader_uuid_from_targets(receiver, targets)
314
- if validation["ok"]:
315
- validation = _validate_leader_receiver(receiver)
316
- if not validation["ok"]:
317
- readopt = _try_readopt_leader_pane(workspace, state, receiver, pane_info, targets, owner_record, receiver_provider, source, event_log)
318
- if readopt is not None:
319
- return readopt, {"ok": True, "pane": pane_info, "readopted": True, "warning": None}
320
- event_log.write("leader_receiver.attach_failed", target=pane or pane_info.get("pane_id"), discovery=discovery, provider=provider, reason=validation["reason"], error=validation.get("error"), source=source, uuid_prefix=str(identity.get("leader_session_uuid") or "")[:12])
321
- raise RuntimeError(_strict_leader_validation_error(validation))
322
- if validation.get("warning"):
323
- receiver["warning"] = validation["warning"]
324
- state["leader_receiver"] = receiver
325
- event_log.write("leader_receiver.attached", target=receiver["pane_id"], session_name=receiver["session_name"], window_index=receiver["window_index"], window_name=receiver["window_name"], pane_index=receiver["pane_index"], pane_tty=receiver["pane_tty"], pane_current_command=receiver["pane_current_command"], provider=receiver_provider, requested_provider=provider if receiver_provider != provider else None, discovery=discovery, source=source, uuid_prefix=str(identity.get("leader_session_uuid") or "")[:12], leader_session_uuid_source=identity.get("leader_session_uuid_source"))
326
- return receiver, validation
327
-
328
-
329
- def _set_tmux_leader_environment(receiver: dict[str, Any], identity: dict[str, Any], event_log: EventLog, run_cmd: Any) -> None:
330
- session_name = receiver.get("session_name")
331
- if not session_name:
332
- return
333
- failures: dict[str, str] = {}
334
- for key, value in leader_env_exports(receiver, identity).items():
335
- proc = run_cmd(["tmux", "set-environment", "-t", str(session_name), key, value], timeout=5)
336
- if proc.returncode != 0:
337
- failures[key] = proc.stderr.strip() or "tmux set-environment failed"
338
- event_log.write(
339
- "leader_receiver.first_time_env_seeded",
340
- pane_id=receiver.get("pane_id"),
341
- session_name=session_name,
342
- ok=not failures,
343
- failed_keys=sorted(failures),
344
- )
345
-
346
- def _strict_leader_validation_error(validation: dict[str, Any]) -> str:
347
- return (
348
- f"leader pane validation failed: {validation['reason']}. "
349
- "tmux leader pane validation could not bind the recorded pane. "
350
- "first quick-start uses cwd+command match only; this team already has team_owner "
351
- "so strict UUID gate applies; use team-agent takeover --confirm if you intend to take over"
352
- )
353
-
354
-
355
- def leader_identity(workspace: Path, team: str | None = None) -> dict[str, Any]:
356
- state = _load_identity_state(workspace, team)
357
- identity = _leader_identity_context(workspace, team=team, state=state)
358
- receiver = state.get("leader_receiver") if isinstance(state.get("leader_receiver"), dict) else {}
359
- return {
360
- "ok": True,
361
- "uuid_prefix": str(identity["leader_session_uuid"])[:12],
362
- "machine_fingerprint": identity["machine_fingerprint"],
363
- "workspace_abspath": identity["workspace_abspath"],
364
- "os_user": identity["os_user"],
365
- "team_id": identity["team_id"],
366
- "current_pane_id": os.environ.get("TEAM_AGENT_LEADER_PANE_ID") or os.environ.get("TMUX_PANE") or None,
367
- "last_seen_at": receiver.get("attached_at") or receiver.get("last_seen_at"),
368
- "source": identity["leader_session_uuid_source"],
369
- }
370
-
371
-
372
- _LEASE_REASON_ENUM = frozenset(
373
- {
374
- "vacant_acquired",
375
- "previous_owner_pane_dead",
376
- "previous_owner_alive_refused",
377
- "owner_epoch_advanced",
378
- "force_confirm_required",
379
- "caller_not_leader_shaped",
380
- "caller_cwd_mismatch",
381
- "not_in_tmux_pane",
382
- }
383
- )
384
- _LEASE_REBIND_REQUIRED_REASONS = frozenset(
385
- {"not_in_tmux_pane", "caller_not_leader_shaped", "caller_cwd_mismatch"}
386
- )
387
-
388
- # MED1/MED3 (spark, 2026-05-27): one lease mutex serializes every lease mutation —
389
- # takeover, claim-leader, attach-leader, and autobind. It is the "send" lock so that
390
- # ownership transfer also serializes against the send mutator (a concurrent send by
391
- # the old owner cannot race a rebind). takeover must stay on this lock for the same
392
- # reason, so the three verbs share a single named critical section.
393
- LEADER_OWNERSHIP_LOCK = "send"
394
-
395
-
396
- def _lease_caller_pane() -> str:
397
- return os.environ.get("TEAM_AGENT_LEADER_PANE_ID") or os.environ.get("TMUX_PANE") or ""
398
-
399
-
400
- def _lease_epoch(owner: dict[str, Any] | None, receiver: dict[str, Any] | None) -> int:
401
- return int((owner or {}).get("owner_epoch") or (receiver or {}).get("owner_epoch") or 0)
402
-
403
-
404
- def _pane_is_live_leader(target: dict[str, Any] | None) -> bool:
405
- # C1/C2: liveness is a live tmux probe. A pane is a live leader if the
406
- # process tree carries the leader session env (set even when a child command
407
- # is foreground), or the pane's current command is a provider leader host.
408
- if not isinstance(target, dict):
409
- return False
410
- from team_agent.messaging.leader_panes import _leader_command_looks_usable, _leader_command_provider, _target_leader_session_uuid
411
- if _target_leader_session_uuid(target):
412
- return True
413
- command = str(target.get("pane_current_command", ""))
414
- return _leader_command_looks_usable(command, "") or _leader_command_provider(command) is not None
415
-
416
-
417
- def _owner_pane_is_live(target: dict[str, Any] | None, owner_record: dict[str, Any] | None) -> bool:
418
- # MED2 (spark, 2026-05-27): a recorded owner is only "live" when the candidate
419
- # pane carries the OWNER's identity, not merely a leader-looking command name.
420
- # When the owner has a recorded leader_session_uuid, that uuid is the identity:
421
- # a stray node/claude pane without the matching uuid is not the owner (so a
422
- # dead-owner recover proceeds), and the real owner is still live even with a
423
- # non-leader foreground command as long as its session uuid is in the tree.
424
- if not isinstance(target, dict):
425
- return False
426
- owner = owner_record or {}
427
- owner_uuid = str(owner.get("leader_session_uuid") or "")
428
- if owner_uuid:
429
- from team_agent.messaging.leader_panes import _target_leader_session_uuid
430
- return _target_leader_session_uuid(target) == owner_uuid
431
- # No recorded uuid: fall back to provider identity (process tree / command for
432
- # the owner's provider) rather than any leader-looking command.
433
- owner_provider = str(owner.get("provider") or "")
434
- if owner_provider:
435
- from team_agent.messaging.leader_panes import _leader_command_looks_usable, _target_leader_session_uuid
436
- if _target_leader_session_uuid(target):
437
- return True
438
- return _leader_command_looks_usable(str(target.get("pane_current_command", "")), owner_provider)
439
- return _pane_is_live_leader(target)
440
-
441
-
442
- def _cwd_inside_workspace(cwd: str | None, workspace: Path) -> bool:
443
- # C7/C8: realpath both sides; membership is subtree containment.
444
- if not cwd:
445
- return True
446
- ws = os.path.realpath(str(workspace.resolve()))
447
- candidate = os.path.realpath(str(cwd))
448
- return candidate == ws or candidate.startswith(ws + os.sep)
449
-
450
-
451
- def _caller_pane_eligibility(target: dict[str, Any] | None, workspace: Path) -> dict[str, Any]:
452
- # C5: acquire binds the caller pane only when it is leader-shaped and its cwd
453
- # is inside the workspace. A plain shell / worker pane never self-binds.
454
- if not _pane_is_live_leader(target):
455
- return {"ok": False, "reason": "caller_not_leader_shaped", "action": "run team-agent claim-leader from a leader (claude/codex) tmux pane"}
456
- if not _cwd_inside_workspace((target or {}).get("pane_current_path"), workspace):
457
- return {"ok": False, "reason": "caller_cwd_mismatch", "action": "run from a leader pane whose cwd is inside this workspace"}
458
- return {"ok": True}
459
-
460
-
461
- def _lease_refused(reason: str, *, action: str | None = None, **extra: Any) -> dict[str, Any]:
462
- result: dict[str, Any] = {"ok": False, "status": "refused", "reason": reason}
463
- if action:
464
- result["action"] = action
465
- result.update(extra)
466
- return result
467
-
468
-
469
- def _emit_lease_refusal(
470
- event_log: EventLog,
471
- reason: str,
472
- owner: dict[str, Any] | None,
473
- old_pane: str | None,
474
- new_pane: str | None,
475
- team_id: str | None,
476
- host: str,
477
- os_user: str,
478
- ) -> None:
479
- # C20/C21/C22: every refusal emits a structured audit event with a closed-enum
480
- # reason, redacted uuid prefix, old/new pane id, host, and OS user.
481
- name = "leader_receiver.rebind_required" if reason in _LEASE_REBIND_REQUIRED_REASONS else "leader_receiver.claim_refused"
482
- event_log.write(
483
- name,
484
- reason=reason,
485
- old_pane_id=old_pane,
486
- new_pane_id=new_pane,
487
- uuid_prefix=str((owner or {}).get("leader_session_uuid") or "")[:8],
488
- team_id=team_id,
489
- host=host,
490
- os_user=os_user,
491
- )
492
-
493
-
494
- def _try_readopt_leader_pane(
495
- workspace: Path,
496
- state: dict[str, Any],
497
- receiver: dict[str, Any],
498
- pane_info: dict[str, Any],
499
- targets: dict[str, Any],
500
- owner_record: dict[str, Any],
501
- receiver_provider: str,
502
- source: str,
503
- event_log: EventLog,
504
- ) -> dict[str, Any] | None:
505
- # C4/C11/C12: attach-leader converges on the lease claim. When the strict UUID
506
- # gate would refuse, re-adopt the pane instead IF it is a live workspace leader
507
- # (real injected uuid + cwd inside the workspace subtree) and the lease is either
508
- # vacant or already owned by that same identity. A genuinely different live owner
509
- # still requires explicit takeover.
510
- from team_agent.messaging.leader_panes import _leader_command_looks_usable, _target_leader_session_uuid
511
- target_list = targets.get("targets", []) if isinstance(targets, dict) and targets.get("ok") else []
512
- pane_target = next((item for item in target_list if isinstance(item, dict) and str(item.get("pane_id")) == str(pane_info.get("pane_id"))), None)
513
- pane_uuid = _target_leader_session_uuid(pane_target or {}) or _target_leader_session_uuid(pane_info) or str(owner_record.get("leader_session_uuid") or receiver.get("leader_session_uuid") or "")
514
- if not _cwd_inside_workspace(pane_info.get("pane_current_path"), workspace):
515
- return None
516
- if not _leader_command_looks_usable(str(pane_info.get("pane_current_command", "")), receiver_provider):
517
- return None
518
- owner_pane = str(owner_record.get("pane_id") or "")
519
- owner_uuid = str(owner_record.get("leader_session_uuid") or "")
520
- target_uuid = _target_leader_session_uuid(pane_target or {})
521
- if owner_pane and owner_pane != str(pane_info.get("pane_id") or "") and (not owner_uuid or target_uuid != owner_uuid):
522
- return None
523
- epoch = _lease_epoch(owner_record, receiver) + (1 if owner_record else 0)
524
- receiver.update({
525
- "pane_id": pane_info["pane_id"],
526
- "session_name": pane_info.get("session_name"),
527
- "window_index": pane_info.get("window_index"),
528
- "window_name": pane_info.get("window_name"),
529
- "pane_index": pane_info.get("pane_index"),
530
- "pane_tty": pane_info.get("pane_tty"),
531
- "pane_current_command": pane_info.get("pane_current_command"),
532
- "leader_session_uuid": pane_uuid,
533
- "owner_epoch": epoch,
534
- "discovery": "attach_readopt",
535
- })
536
- receiver.pop("warning", None)
537
- old_pane = owner_record.get("pane_id") or (state.get("leader_receiver") or {}).get("pane_id")
538
- state["team_owner"] = {
539
- "pane_id": pane_info["pane_id"],
540
- "provider": receiver.get("provider") or receiver_provider,
541
- "machine_fingerprint": owner_record.get("machine_fingerprint") or pane_info.get("machine_fingerprint") or "",
542
- "leader_session_uuid": pane_uuid,
543
- "owner_epoch": epoch,
544
- "claimed_at": datetime.now(timezone.utc).isoformat(),
545
- "claimed_via": "attach-leader",
546
- }
547
- state["leader_receiver"] = receiver
548
- _write_lease_dual_state(workspace, state)
549
- if old_pane and old_pane != pane_info["pane_id"]:
550
- event_log.write("owner.adopted_on_restart", reason="attach_readopt", old_pane_id=old_pane, new_pane_id=pane_info["pane_id"], owner_epoch=epoch, uuid_prefix=pane_uuid[:8], team_id=team_state_key(state))
551
- event_log.write("leader_receiver.rebind_applied", reason="attach_readopt", old_pane_id=old_pane, new_pane_id=pane_info["pane_id"], owner_epoch=epoch, uuid_prefix=pane_uuid[:8], team_id=team_state_key(state))
552
- event_log.write("leader_receiver.attached", target=pane_info["pane_id"], session_name=pane_info.get("session_name"), provider=receiver.get("provider"), discovery="attach_readopt", source=source, owner_epoch=epoch, uuid_prefix=pane_uuid[:8])
553
- return receiver
554
-
555
-
556
- def _detect_dual_state_divergence(workspace: Path, state: dict[str, Any]) -> dict[str, Any] | None:
557
- # C18: the workspace-level state.json and the team-level runtime snapshot must
558
- # agree on owner_uuid, receiver_pane_id, and owner_epoch. Detect a pre-existing
559
- # split so the repair can be audited.
560
- session = state.get("session_name")
561
- if not session:
562
- return None
563
- from team_agent.restart.snapshot import load_snapshot_state, team_runtime_snapshot_dir
564
- snap_path = team_runtime_snapshot_dir(workspace, str(session)) / "state.json"
565
- if not snap_path.exists():
566
- return None
567
- snap = load_snapshot_state(snap_path) or {}
568
- ws_owner = state.get("team_owner") if isinstance(state.get("team_owner"), dict) else {}
569
- snap_owner = snap.get("team_owner") if isinstance(snap.get("team_owner"), dict) else {}
570
- ws_receiver = state.get("leader_receiver") if isinstance(state.get("leader_receiver"), dict) else {}
571
- snap_receiver = snap.get("leader_receiver") if isinstance(snap.get("leader_receiver"), dict) else {}
572
- diverged = (
573
- ws_owner.get("pane_id") != snap_owner.get("pane_id")
574
- or ws_owner.get("leader_session_uuid") != snap_owner.get("leader_session_uuid")
575
- or _lease_epoch(ws_owner, ws_receiver) != _lease_epoch(snap_owner, snap_receiver)
576
- or ws_receiver.get("pane_id") != snap_receiver.get("pane_id")
577
- )
578
- if not diverged:
579
- return None
580
- return {
581
- "workspace_owner_pane": ws_owner.get("pane_id"),
582
- "team_owner_pane": snap_owner.get("pane_id"),
583
- "workspace_receiver_pane": ws_receiver.get("pane_id"),
584
- "team_receiver_pane": snap_receiver.get("pane_id"),
585
- }
586
-
587
-
588
- def _write_lease_dual_state(workspace: Path, state: dict[str, Any]) -> None:
589
- # C17: write team_owner + leader_receiver to both state locations in one lock
590
- # hold. The workspace-level state.json and the team-level runtime snapshot
591
- # (teams/<session>/state.json) must never diverge after a lease mutation.
592
- save_team_scoped_state(workspace, state)
593
- if state.get("session_name"):
594
- from team_agent.restart.snapshot import save_team_runtime_snapshot
595
- save_team_runtime_snapshot(workspace, state)
596
-
597
-
598
- def _claim_lease_no_incident(
599
- workspace: Path,
600
- state: dict[str, Any],
601
- team: str | None,
602
- team_id: str,
603
- caller_pane: str,
604
- confirm: bool,
605
- event_log: EventLog,
606
- ) -> dict[str, Any]:
607
- # Gap 39 unified lease: no ambiguous incident is recorded, so this is a direct
608
- # acquire/claim against live evidence (not the Gap 26 broadcast-claim flow).
609
- from team_agent.runtime import core_list_targets
610
- owner = state.get("team_owner") if isinstance(state.get("team_owner"), dict) else {}
611
- receiver = state.get("leader_receiver") if isinstance(state.get("leader_receiver"), dict) else {}
612
- precheck_epoch = _lease_epoch(owner, receiver)
613
- host = str(owner.get("machine_fingerprint") or _identity_machine_fingerprint(state))
614
- os_user = _identity_os_user()
615
-
616
- if not caller_pane:
617
- _emit_lease_refusal(event_log, "not_in_tmux_pane", owner, receiver.get("pane_id"), None, team_id, host, os_user)
618
- return _lease_refused("not_in_tmux_pane", action="run team-agent claim-leader from the leader's tmux pane")
619
-
620
- targets_result = core_list_targets()
621
- targets = targets_result.get("targets", []) if isinstance(targets_result, dict) and targets_result.get("ok") else []
622
- by_pane = {str(item.get("pane_id")): item for item in targets if isinstance(item, dict)}
623
-
624
- bound_pane = receiver.get("pane_id") or owner.get("pane_id")
625
- bound_alive = _owner_pane_is_live(by_pane.get(str(bound_pane)), owner) if bound_pane else False
626
-
627
- if bound_pane and str(bound_pane) == str(caller_pane):
628
- return {
629
- "ok": True,
630
- "status": "already_bound",
631
- "leader_receiver": receiver or None,
632
- "team_owner": owner or None,
633
- "owner_epoch": precheck_epoch,
634
- }
635
-
636
- caller_target = by_pane.get(str(caller_pane))
637
- eligibility = _caller_pane_eligibility(caller_target, workspace)
638
- if not eligibility["ok"]:
639
- _emit_lease_refusal(event_log, eligibility["reason"], owner, bound_pane, caller_pane, team_id, host, os_user)
640
- return _lease_refused(eligibility["reason"], action=eligibility.get("action"))
641
-
642
- if bound_alive and not confirm:
643
- # C4/C13: a live recorded owner is never stolen without --confirm. The audit
644
- # reason classifies the WHY (owner alive); the result hint tells the operator
645
- # the action (rerun with --confirm).
646
- _emit_lease_refusal(event_log, "previous_owner_alive_refused", owner, bound_pane, caller_pane, team_id, host, os_user)
647
- return _lease_refused(
648
- "force_confirm_required",
649
- action="rerun with --confirm to take over the live leader pane",
650
- bound_pane_id=bound_pane,
651
- owner_epoch=precheck_epoch,
652
- )
653
-
654
- # C3/C15: revalidate under the lock. Re-read both the persisted epoch and live
655
- # liveness; if the epoch advanced or a previously-dead owner revived since the
656
- # precheck, abort the claim without double-binding (lost the epoch race).
657
- locked_state = select_runtime_state(workspace, team)
658
- locked_owner = locked_state.get("team_owner") if isinstance(locked_state.get("team_owner"), dict) else {}
659
- locked_receiver = locked_state.get("leader_receiver") if isinstance(locked_state.get("leader_receiver"), dict) else {}
660
- locked_epoch = _lease_epoch(locked_owner, locked_receiver)
661
- recheck_result = core_list_targets()
662
- recheck_targets = recheck_result.get("targets", []) if isinstance(recheck_result, dict) and recheck_result.get("ok") else []
663
- recheck_by_pane = {str(item.get("pane_id")): item for item in recheck_targets if isinstance(item, dict)}
664
- revived = bool(bound_pane) and not bound_alive and _owner_pane_is_live(recheck_by_pane.get(str(bound_pane)), owner)
665
- if locked_epoch != precheck_epoch or (revived and not confirm):
666
- _emit_lease_refusal(event_log, "owner_epoch_advanced", locked_owner or owner, bound_pane, caller_pane, team_id, host, os_user)
667
- return _lease_refused(
668
- "owner_epoch_advanced",
669
- bound_pane_id=bound_pane,
670
- owner_epoch=max(locked_epoch, precheck_epoch),
671
- )
672
-
673
- divergence = _detect_dual_state_divergence(workspace, state)
674
- # C10/C12: the caller pane's injected TEAM_AGENT_LEADER_SESSION_UUID is the
675
- # authoritative identity for the bind; fall back to the recorded owner/receiver
676
- # uuid, then to the deterministic derivation.
677
- from team_agent.messaging.leader_panes import _target_leader_session_uuid
678
- next_epoch = precheck_epoch + 1
679
- leader_uuid = str(
680
- _target_leader_session_uuid(caller_target or {})
681
- or owner.get("leader_session_uuid")
682
- or receiver.get("leader_session_uuid")
683
- or _leader_identity_context(workspace, team=team, state=state)["leader_session_uuid"]
684
- )
685
- new_receiver = _receiver_from_claim_target(caller_target, receiver, leader_uuid, next_epoch)
686
- new_owner = {
687
- "pane_id": caller_pane,
688
- "provider": new_receiver.get("provider") or owner.get("provider") or "codex",
689
- "machine_fingerprint": host,
690
- "leader_session_uuid": leader_uuid,
691
- "owner_epoch": next_epoch,
692
- "claimed_at": datetime.now(timezone.utc).isoformat(),
693
- "claimed_via": "claim-leader",
694
- }
695
- state["team_owner"] = new_owner
696
- state["leader_receiver"] = new_receiver
697
- _write_lease_dual_state(workspace, state)
698
- dead_owner = bool(bound_pane) and not bound_alive
699
- reason = "previous_owner_pane_dead" if dead_owner else "vacant_acquired"
700
- if dead_owner:
701
- event_log.write(
702
- "owner.adopted_on_restart",
703
- reason=reason,
704
- old_pane_id=bound_pane,
705
- new_pane_id=caller_pane,
706
- owner_epoch=next_epoch,
707
- uuid_prefix=leader_uuid[:8],
708
- team_id=team_id,
709
- host=host,
710
- os_user=os_user,
711
- )
712
- event_log.write(
713
- "leader_receiver.rebind_applied",
714
- reason=reason,
715
- old_pane_id=bound_pane,
716
- new_pane_id=caller_pane,
717
- owner_epoch=next_epoch,
718
- uuid_prefix=leader_uuid[:8],
719
- team_id=team_id,
720
- )
721
- event_log.write(
722
- "owner_epoch_advanced",
723
- reason=reason,
724
- old_pane_id=bound_pane,
725
- new_pane_id=caller_pane,
726
- owner_epoch=next_epoch,
727
- uuid_prefix=leader_uuid[:8],
728
- team_id=team_id,
729
- )
730
- if divergence:
731
- # C18/C19: the workspace-level and team-level state had diverged before this
732
- # mutation; the single dual-write above re-converged them.
733
- event_log.write("leader_receiver.state_divergence_repaired", team_id=team_id, owner_epoch=next_epoch, new_pane_id=caller_pane, **divergence)
734
- return {
735
- "ok": True,
736
- "status": "claimed",
737
- "leader_receiver": new_receiver,
738
- "team_owner": new_owner,
739
- "owner_epoch": next_epoch,
740
- "reason": reason,
741
- }
742
-
743
-
744
- def claim_leader(workspace: Path, team: str | None = None, confirm: bool = False) -> dict[str, Any]:
745
- from team_agent.runtime import RuntimeError, _runtime_lock, core_list_targets
746
- current_pane = _lease_caller_pane()
747
- with _runtime_lock(workspace, LEADER_OWNERSHIP_LOCK):
748
- state = select_runtime_state(workspace, team)
749
- event_log = EventLog(workspace)
750
- team_id = team_state_key(state)
751
- incident = _latest_ambiguous_incident(event_log, team_id)
752
- if not incident:
753
- return _claim_lease_no_incident(workspace, state, team, team_id, current_pane, confirm, event_log)
754
- if not current_pane:
755
- return {"ok": False, "status": "refused", "reason": "no_caller_pane", "action": "run from a tmux leader pane"}
756
- candidates = [str(item) for item in incident.get("candidates", [])]
757
- if current_pane not in candidates:
758
- return {"ok": False, "status": "refused", "reason": "caller_not_candidate", "candidates": candidates}
759
- receiver = state.get("leader_receiver") or {}
760
- if receiver.get("pane_id") == current_pane:
761
- return {"ok": True, "status": "already_bound", "leader_receiver": receiver}
762
- if _incident_already_claimed(event_log, str(incident.get("incident_id"))):
763
- return _claim_lost_race(receiver)
764
- if receiver.get("pane_id") in candidates and receiver.get("pane_id") != incident.get("old_pane_id"):
765
- return _claim_lost_race(receiver)
766
- if not confirm:
767
- return {"ok": True, "status": "dry_run", "would_bind_pane_id": current_pane, "candidates": candidates}
768
- targets = core_list_targets()
769
- if not targets.get("ok"):
770
- raise RuntimeError(str(targets.get("error") or "tmux target scan failed"))
771
- target = next((item for item in targets.get("targets", []) if item.get("pane_id") == current_pane), None)
772
- if not target:
773
- return {"ok": False, "status": "refused", "reason": "candidate_pane_missing", "pane_id": current_pane}
774
- owner = state.setdefault("team_owner", {})
775
- expected_uuid = str(owner.get("leader_session_uuid") or _leader_identity_context(workspace, team=team, state=state)["leader_session_uuid"])
776
- target_uuid = _target_leader_session_uuid(target)
777
- if target_uuid != expected_uuid:
778
- return {"ok": False, "status": "refused", "reason": "leader_session_uuid_mismatch", "uuid_prefix": expected_uuid[:12]}
779
- epoch = int(owner.get("owner_epoch") or receiver.get("owner_epoch") or 0) + 1
780
- owner.update({"pane_id": current_pane, "owner_epoch": epoch, "claimed_at": datetime.now(timezone.utc).isoformat(), "claimed_via": "claim-leader"})
781
- state["leader_receiver"] = _receiver_from_claim_target(target, receiver, expected_uuid, epoch)
782
- # HIGH (spark, 2026-05-27): the multi-candidate claim branch must write both
783
- # state locations atomically (workspace state.json + team/<session> snapshot),
784
- # exactly like the no-incident lease path, so the branches never split state.
785
- divergence = _detect_dual_state_divergence(workspace, state)
786
- _write_lease_dual_state(workspace, state)
787
- if divergence:
788
- event_log.write("leader_receiver.state_divergence_repaired", team_id=team_id, owner_epoch=epoch, new_pane_id=current_pane, **divergence)
789
- losers = [pane for pane in candidates if pane != current_pane]
790
- event_log.write(
791
- "leader_receiver.claim_applied",
792
- incident_id=incident.get("incident_id"),
793
- winner_pane_id=current_pane,
794
- losers=losers,
795
- owner_epoch=epoch,
796
- uuid_prefix=expected_uuid[:12],
797
- )
798
- # Stage 11.9 (Gap 26 Mac mini Scenario 3): result watchers that stalled while the
799
- # broadcast was waiting for a human claim need fresh budget against the newly bound
800
- # pane. Per-watcher leader_receiver.claim_requeue events + immediate retry.
801
- from team_agent.message_store import MessageStore
802
- from team_agent.messaging.result_delivery import requeue_after_claim_leader
803
- requeued = requeue_after_claim_leader(
804
- workspace,
805
- MessageStore(workspace),
806
- event_log,
807
- team_state_key(state),
808
- current_pane,
809
- incident_ts=incident.get("ts"),
810
- )
811
- response: dict[str, Any] = {
812
- "ok": True,
813
- "status": "claimed",
814
- "leader_receiver": state["leader_receiver"],
815
- "owner_epoch": epoch,
816
- "losers": losers,
817
- "requeued_watchers": [item["watcher_id"] for item in requeued],
818
- }
819
- # Stage 13 (silent-loss arm mailbox-hint route, 2026-05-26 second roundtable):
820
- # the framework cannot guarantee every worker message reached the leader pane during
821
- # the ambiguous-state window (retry budgets may have exhausted before the human
822
- # claimed). Pointing the leader agent at the inbox lets it self-recover by reading
823
- # the messages that landed in storage but never injected to a pane.
824
- incident_ts = incident.get("ts")
825
- if incident_ts:
826
- response["inbox_hint"] = {
827
- "message": (
828
- "During the previous ambiguous-leader state, some worker messages may "
829
- "not have been auto-delivered to this pane. Run the command below to "
830
- "retrieve them."
831
- ),
832
- "command": f"team-agent inbox leader --since {incident_ts}",
833
- "since": incident_ts,
834
- "incident_id": incident.get("incident_id"),
835
- }
836
- return response
837
-
838
-
839
- def _latest_ambiguous_incident(event_log: EventLog, team_id: str) -> dict[str, Any] | None:
840
- for event in reversed(event_log.tail(200)):
841
- if event.get("event") != "leader_receiver.ambiguous_candidates":
842
- continue
843
- if event.get("team_id") in {None, team_id}:
844
- return event
845
- return None
846
-
847
-
848
- def _incident_already_claimed(event_log: EventLog, incident_id: str) -> bool:
849
- return any(event.get("event") == "leader_receiver.claim_applied" and event.get("incident_id") == incident_id for event in event_log.tail(200))
850
-
851
-
852
- def _claim_lost_race(receiver: dict[str, Any]) -> dict[str, Any]:
853
- return {"ok": False, "status": "refused", "reason": "owner_epoch_advanced", "error": f"team already bound to pane {receiver.get('pane_id')}; you lost the race", "bound_pane_id": receiver.get("pane_id"), "owner_epoch": receiver.get("owner_epoch")}
854
-
855
-
856
- def _target_leader_session_uuid(target: dict[str, Any]) -> str:
857
- env = target.get("leader_env") if isinstance(target.get("leader_env"), dict) else {}
858
- return str(target.get("leader_session_uuid") or env.get("TEAM_AGENT_LEADER_SESSION_UUID") or "")
859
-
860
-
861
- def _receiver_from_claim_target(target: dict[str, Any], previous: dict[str, Any], leader_uuid: str, owner_epoch: int) -> dict[str, Any]:
862
- return {
863
- "mode": "direct_tmux",
864
- "status": "attached",
865
- "provider": previous.get("provider") or "codex",
866
- "pane_id": target["pane_id"],
867
- "session_name": target.get("session_name"),
868
- "window_index": str(target.get("window_index")),
869
- "window_name": target.get("window_name"),
870
- "pane_index": str(target.get("pane_index")),
871
- "pane_tty": target.get("pane_tty"),
872
- "pane_current_command": target.get("pane_current_command"),
873
- "leader_session_uuid": leader_uuid,
874
- "owner_epoch": owner_epoch,
875
- "attached_at": datetime.now(timezone.utc).isoformat(),
876
- "discovery": "claim_leader",
877
- }
878
-
879
-
880
- def autobind_leader_receiver_from_env(
881
- workspace: Path,
882
- provider: str,
883
- source: str,
884
- ) -> dict[str, Any] | None:
885
- tmux_pane = os.environ.get("TMUX_PANE")
886
- if not tmux_pane:
887
- return None
888
- from team_agent.runtime import _runtime_lock, ensure_workspace_dirs
889
- ensure_workspace_dirs(workspace)
890
- # MED1/MED3: the startup autobind is a lease mutation; hold the single lease
891
- # mutex so it cannot interleave with takeover / claim / attach / send.
892
- with _runtime_lock(workspace, LEADER_OWNERSHIP_LOCK):
893
- state = load_runtime_state(workspace)
894
- event_log = EventLog(workspace)
895
- try:
896
- receiver, _validation = attach_leader_to_state(
897
- workspace,
898
- state,
899
- pane=tmux_pane,
900
- provider=provider,
901
- event_log=event_log,
902
- source=source,
903
- )
904
- except Exception as exc:
905
- event_log.write(
906
- "leader_receiver.autobind_skipped",
907
- pane=tmux_pane,
908
- provider=provider,
909
- source=source,
910
- error=str(exc),
911
- )
912
- return None
913
- save_runtime_state(workspace, state)
914
- return receiver
915
-
916
-
917
- __all__ = [
918
- "attach_leader",
919
- "attach_leader_to_state",
920
- "autobind_leader_receiver_from_env",
921
- "claim_leader",
922
- "leader_identity",
923
- "leader_session_name",
924
- "leader_start_plan",
925
- "start_leader",
926
- ]