@team-agent/installer 0.5.49 → 0.5.51

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 (90) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +6 -4
  4. package/crates/team-agent/src/cli/emit.rs +91 -39
  5. package/crates/team-agent/src/cli/mod.rs +33 -15
  6. package/crates/team-agent/src/cli/named_address.rs +82 -53
  7. package/crates/team-agent/src/cli/send/coordinator.rs +163 -0
  8. package/crates/team-agent/src/cli/send/mailbox.rs +99 -0
  9. package/crates/team-agent/src/cli/send/persist.rs +154 -0
  10. package/crates/team-agent/src/cli/send/presentation.rs +333 -0
  11. package/crates/team-agent/src/cli/send/resolve.rs +361 -0
  12. package/crates/team-agent/src/cli/send.rs +103 -1308
  13. package/crates/team-agent/src/cli/spec.rs +2 -2
  14. package/crates/team-agent/src/cli/status_port/agents.rs +358 -0
  15. package/crates/team-agent/src/cli/status_port/approvals.rs +79 -0
  16. package/crates/team-agent/src/cli/status_port/compact.rs +207 -0
  17. package/crates/team-agent/src/cli/status_port/format.rs +145 -0
  18. package/crates/team-agent/src/cli/status_port/inbox.rs +36 -0
  19. package/crates/team-agent/src/cli/status_port/runtime.rs +195 -0
  20. package/crates/team-agent/src/cli/status_port/snapshot.rs +181 -0
  21. package/crates/team-agent/src/cli/status_port/store.rs +412 -0
  22. package/crates/team-agent/src/cli/status_port/tests.rs +54 -0
  23. package/crates/team-agent/src/cli/status_port.rs +47 -1548
  24. package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -1
  25. package/crates/team-agent/src/cli/tests/named_address.rs +9 -7
  26. package/crates/team-agent/src/cli/tests/run_delegation.rs +2 -3
  27. package/crates/team-agent/src/cli/tests/status_send.rs +17 -33
  28. package/crates/team-agent/src/cli/types.rs +5 -8
  29. package/crates/team-agent/src/coordinator/conpty_shim.rs +34 -30
  30. package/crates/team-agent/src/coordinator/steps/abnormal.rs +135 -13
  31. package/crates/team-agent/src/coordinator/tick.rs +37 -0
  32. package/crates/team-agent/src/db/agent_health_capture.rs +18 -13
  33. package/crates/team-agent/src/db/message_store.rs +154 -44
  34. package/crates/team-agent/src/event_log.rs +73 -0
  35. package/crates/team-agent/src/leader/start.rs +28 -4
  36. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +424 -0
  37. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +297 -0
  38. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +160 -0
  39. package/crates/team-agent/src/lifecycle/launch/approval.rs +134 -0
  40. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +492 -0
  41. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +297 -0
  42. package/crates/team-agent/src/lifecycle/launch/identity.rs +372 -0
  43. package/crates/team-agent/src/lifecycle/launch/layout.rs +313 -0
  44. package/crates/team-agent/src/lifecycle/launch/leader_context.rs +478 -0
  45. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +201 -0
  46. package/crates/team-agent/src/lifecycle/launch/ownership.rs +66 -0
  47. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +477 -0
  48. package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +278 -0
  49. package/crates/team-agent/src/lifecycle/launch/readiness.rs +123 -0
  50. package/crates/team-agent/src/lifecycle/launch/spawn.rs +377 -0
  51. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +434 -0
  52. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +499 -0
  53. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +438 -0
  54. package/crates/team-agent/src/lifecycle/launch.rs +119 -5351
  55. package/crates/team-agent/src/lifecycle/restart/agent.rs +44 -26
  56. package/crates/team-agent/src/lifecycle/restart/common.rs +53 -27
  57. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +67 -26
  58. package/crates/team-agent/src/lifecycle/restart/remove.rs +435 -72
  59. package/crates/team-agent/src/lifecycle/restart.rs +1 -1
  60. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +575 -17
  61. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +55 -2
  62. package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +24 -1
  63. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +1 -1
  64. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +30 -7
  65. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +8 -4
  66. package/crates/team-agent/src/mcp_server/mod.rs +2 -2
  67. package/crates/team-agent/src/mcp_server/tests/send.rs +22 -15
  68. package/crates/team-agent/src/mcp_server/tests/wire.rs +6 -0
  69. package/crates/team-agent/src/mcp_server/tools.rs +26 -15
  70. package/crates/team-agent/src/mcp_server/wire.rs +2 -18
  71. package/crates/team-agent/src/messaging/activity.rs +4 -2
  72. package/crates/team-agent/src/messaging/address.rs +86 -0
  73. package/crates/team-agent/src/messaging/delivery.rs +165 -39
  74. package/crates/team-agent/src/messaging/helpers.rs +17 -13
  75. package/crates/team-agent/src/messaging/leader_receiver.rs +60 -35
  76. package/crates/team-agent/src/messaging/mod.rs +11 -2
  77. package/crates/team-agent/src/messaging/persist.rs +309 -0
  78. package/crates/team-agent/src/messaging/results.rs +16 -24
  79. package/crates/team-agent/src/messaging/scheduler.rs +4 -2
  80. package/crates/team-agent/src/messaging/selftest.rs +19 -12
  81. package/crates/team-agent/src/messaging/send.rs +133 -58
  82. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +305 -0
  83. package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
  84. package/crates/team-agent/src/messaging/tests/runtime.rs +38 -17
  85. package/crates/team-agent/src/messaging/watchers.rs +13 -3
  86. package/crates/team-agent/src/redaction.rs +72 -2
  87. package/crates/team-agent/src/state/persist.rs +2 -1
  88. package/crates/team-agent/src/state/repository/tests.rs +47 -0
  89. package/crates/team-agent/src/state/repository.rs +59 -16
  90. package/package.json +4 -4
@@ -0,0 +1,499 @@
1
+ use std::collections::{BTreeMap, BTreeSet};
2
+ use std::path::{Path, PathBuf};
3
+ use std::process::Command;
4
+
5
+ use crate::lifecycle::lock::{acquire_agent_lifecycle_lock, LifecycleLockRequest};
6
+ use crate::lifecycle::profile_launch::parse_provider;
7
+ use crate::lifecycle::*;
8
+ use crate::model::enums::{AuthMode, DisplayBackend, PaneLiveness, Provider, ProviderEffort};
9
+ use crate::model::ids::AgentId;
10
+ use crate::model::permissions::{self, AgentPermissionInput};
11
+ use crate::model::yaml::{self, Value};
12
+ use crate::state::persist::load_runtime_state;
13
+ use crate::transport::{PaneId, SessionName, Target, Transport, WindowName};
14
+
15
+ use super::agent_state::running_agent_state;
16
+ use super::identity::{explicit_active_team_key, runtime_team_key_for_spec};
17
+ use super::leader_context::{
18
+ owner_pane_belongs_to_other_team, seed_unbound_launched_owner, unbound_launched_owner,
19
+ };
20
+ use super::spec_state::{has_positive_caller_leader_env, spec_agent_values};
21
+ use super::worker_env::{agent_is_paused, spawn_timestamp_for_agent};
22
+
23
+ pub(super) fn persist_spawn_agent_state(
24
+ workspace: &Path,
25
+ spec_path: &Path,
26
+ spec: &Value,
27
+ session_name: &SessionName,
28
+ transport: &dyn Transport,
29
+ started: &[StartedAgent],
30
+ safety: &DangerousApproval,
31
+ ) -> Result<(), LifecycleError> {
32
+ let state_path = crate::state::persist::runtime_state_path(workspace);
33
+ let mut state = match crate::state::repository::StateRepository::new(workspace)
34
+ .load_workspace_if_exists_without_migrations()
35
+ {
36
+ Ok(Some(state)) => state,
37
+ Ok(None) => serde_json::json!({"agents": {}}),
38
+ Err(crate::state::StateError::Io(error)) => {
39
+ return Err(LifecycleError::StatePersist(format!(
40
+ "{}: {error}",
41
+ state_path.display()
42
+ )))
43
+ }
44
+ Err(crate::state::StateError::Json(error)) => {
45
+ return Err(LifecycleError::StatePersist(format!(
46
+ "{}: {error}",
47
+ state_path.display()
48
+ )))
49
+ }
50
+ Err(error) => {
51
+ return Err(LifecycleError::StatePersist(format!(
52
+ "{}: {error}",
53
+ state_path.display()
54
+ )))
55
+ }
56
+ };
57
+ let team_id = runtime_team_key_for_spec(spec_path, spec, session_name);
58
+ let worker_tmux_socket = launched_worker_tmux_socket(transport, workspace);
59
+ drop_worker_pane_seeded_owner(&mut state, &team_id, started, worker_tmux_socket.as_deref());
60
+ // Only persist running state for agents whose spawn still has a live target.
61
+ let live_windows: BTreeSet<String> = transport
62
+ .list_windows(session_name)
63
+ .unwrap_or_default()
64
+ .into_iter()
65
+ .map(|w| w.as_str().to_string())
66
+ .collect();
67
+ let live_started_agents: BTreeSet<String> = started
68
+ .iter()
69
+ .map(|agent| agent.agent_id.as_str().to_string())
70
+ .collect();
71
+ let pane_pids_by_agent = pane_pids_by_started_agent(transport, started);
72
+ // E5 解耦:profiles 随**角色定义**(team_dir),不随 spec(已迁出到 .team/runtime)。
73
+ // 优先 state.team_dir(角色目录),回落 spec_path.parent()(legacy 同目录布局)。
74
+ let profile_dir = state
75
+ .get("team_dir")
76
+ .and_then(serde_json::Value::as_str)
77
+ .filter(|s| !s.is_empty())
78
+ .map(|dir| Path::new(dir).join("profiles"))
79
+ .unwrap_or_else(|| spec_path.parent().unwrap_or(workspace).join("profiles"));
80
+ let mut agents = serde_json::Map::new();
81
+ let mut spawn_index = 0_u32;
82
+ for agent in spec_agent_values(spec) {
83
+ let Some(id) = agent.get("id").and_then(Value::as_str) else {
84
+ continue;
85
+ };
86
+ let provider = agent
87
+ .get("provider")
88
+ .and_then(Value::as_str)
89
+ .and_then(parse_provider)
90
+ .unwrap_or(Provider::Codex);
91
+ if agent_is_paused(agent) {
92
+ let mut paused = serde_json::Map::new();
93
+ paused.insert("status".to_string(), serde_json::json!("paused"));
94
+ paused.insert("provider".to_string(), serde_json::json!(provider));
95
+ agents.insert(id.to_string(), serde_json::Value::Object(paused));
96
+ continue;
97
+ }
98
+ let started_agent = started.iter().find(|agent| agent.agent_id.as_str() == id);
99
+ let window = started_agent
100
+ .and_then(|started| started.layout_window.as_ref())
101
+ .map(WindowName::as_str)
102
+ .or_else(|| agent.get("window").and_then(Value::as_str))
103
+ .unwrap_or(id);
104
+ if !live_started_agents.contains(id)
105
+ || (!live_windows.is_empty() && !live_windows.contains(window))
106
+ {
107
+ let mut failed = serde_json::Map::new();
108
+ failed.insert("status".to_string(), serde_json::json!("spawn_failed"));
109
+ failed.insert("provider".to_string(), serde_json::json!(provider));
110
+ failed.insert("agent_id".to_string(), serde_json::json!(id));
111
+ failed.insert("window".to_string(), serde_json::json!(window));
112
+ failed.insert(
113
+ "reason".to_string(),
114
+ serde_json::json!("tmux window not present after spawn"),
115
+ );
116
+ agents.insert(id.to_string(), serde_json::Value::Object(failed));
117
+ continue;
118
+ }
119
+ let pane_pid = pane_pids_by_agent.get(id).copied();
120
+ let spawned_at = started_agent
121
+ .map(|started| started.spawned_at.clone())
122
+ .unwrap_or_else(|| spawn_timestamp_for_agent(spawn_index));
123
+ spawn_index = spawn_index.saturating_add(1);
124
+ agents.insert(
125
+ id.to_string(),
126
+ running_agent_state(
127
+ agent,
128
+ id,
129
+ provider,
130
+ workspace,
131
+ workspace,
132
+ &spawned_at,
133
+ &team_id,
134
+ Some(agent_id_to_pane_id(started, id)),
135
+ pane_pid,
136
+ safety,
137
+ started_agent,
138
+ Some(&profile_dir),
139
+ )?,
140
+ );
141
+ }
142
+ if let Some(obj) = state.as_object_mut() {
143
+ obj.insert("agents".to_string(), serde_json::Value::Object(agents));
144
+ } else {
145
+ let mut obj = serde_json::Map::new();
146
+ obj.insert("agents".to_string(), serde_json::Value::Object(agents));
147
+ state = serde_json::Value::Object(obj);
148
+ }
149
+ save_launched_team_state_for_key(workspace, &state, Some(&team_id))
150
+ }
151
+
152
+ pub(super) fn pane_pids_by_started_agent(
153
+ transport: &dyn Transport,
154
+ started: &[StartedAgent],
155
+ ) -> BTreeMap<String, u32> {
156
+ let panes = transport.list_targets().unwrap_or_default();
157
+ started
158
+ .iter()
159
+ .filter_map(|agent| {
160
+ panes
161
+ .iter()
162
+ .find(|pane| pane.pane_id.as_str() == agent.target)
163
+ .and_then(|pane| pane.pane_pid)
164
+ .map(|pid| (agent.agent_id.as_str().to_string(), pid))
165
+ })
166
+ .collect()
167
+ }
168
+
169
+ pub(super) fn agent_id_to_pane_id<'a>(started: &'a [StartedAgent], agent_id: &str) -> &'a str {
170
+ started
171
+ .iter()
172
+ .find(|agent| agent.agent_id.as_str() == agent_id)
173
+ .map(|agent| agent.target.as_str())
174
+ .unwrap_or("")
175
+ }
176
+
177
+ pub(super) fn save_launched_team_state(
178
+ workspace: &Path,
179
+ launched: &serde_json::Value,
180
+ ) -> Result<(), LifecycleError> {
181
+ save_launched_team_state_for_key(workspace, launched, None)
182
+ }
183
+
184
+ pub(super) fn save_launched_team_state_for_key(
185
+ workspace: &Path,
186
+ launched: &serde_json::Value,
187
+ team_key: Option<&str>,
188
+ ) -> Result<(), LifecycleError> {
189
+ let existing = load_runtime_state(workspace).unwrap_or_else(|_| serde_json::json!({}));
190
+ let launched_key = team_key
191
+ .filter(|key| !key.is_empty())
192
+ .map(str::to_string)
193
+ .unwrap_or_else(|| crate::state::projection::team_state_key(launched));
194
+ let mut launched = launched.clone();
195
+ if let Some(obj) = launched.as_object_mut() {
196
+ // RM-039-STAT-001 second-round fix (architect verdict
197
+ // 2026-06-22): the canonical runtime team key MUST be written
198
+ // explicitly to `state.team_key`, not only inferred from
199
+ // `team_dir` by `team_state_key`'s cascade. The historical
200
+ // bug: when `team_dir = "./.team/current"` and the runtime team
201
+ // is `rm039-status-working-891`, `team_state_key` cascades to
202
+ // the team_dir basename (`current`), but `active_team_key` is
203
+ // the real team key. Coordinator tick uses `team_state_key`
204
+ // when saving the team-scoped state, so writes land on
205
+ // `teams.current` instead of `teams.<active_team_key>`. Status
206
+ // reads `teams[active_team_key]`, sees stale data.
207
+ //
208
+ // Writing `team_key=launched_key` here pins the first branch of
209
+ // `team_state_key` so the cascade returns the canonical runtime
210
+ // team key everywhere — coordinator tick, save_team_scoped_state,
211
+ // and status selector all agree.
212
+ obj.insert(
213
+ "team_key".to_string(),
214
+ serde_json::Value::String(launched_key.clone()),
215
+ );
216
+ obj.insert(
217
+ "active_team_key".to_string(),
218
+ serde_json::Value::String(launched_key.clone()),
219
+ );
220
+ obj.entry("is_external_leader".to_string())
221
+ .or_insert(serde_json::Value::Bool(false));
222
+ }
223
+ promote_launched_binding_from_team_entry(&mut launched, &launched_key);
224
+ preserve_existing_leader_topology(&existing, &launched_key, &mut launched);
225
+ drop_foreign_seeded_owner(&existing, &launched_key, &mut launched);
226
+ drop_bare_worker_seeded_owner(&mut launched, &launched_key);
227
+ let merged = if team_key.is_some() {
228
+ merge_workspace_team_state_with_key(&existing, &launched, &launched_key)
229
+ } else {
230
+ crate::state::projection::merge_workspace_team_state(&existing, &launched)
231
+ };
232
+ let mut projected = crate::state::projection::project_top_level_view(&merged, &launched_key);
233
+ drop_unbound_top_level_owner(&mut projected);
234
+ crate::state::repository::StateRepository::new(workspace)
235
+ .save(
236
+ crate::state::repository::StateWriteIntent::LaunchTeam {
237
+ team_key: &launched_key,
238
+ },
239
+ &projected,
240
+ )
241
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))
242
+ }
243
+
244
+ pub(super) fn preserve_existing_leader_topology(
245
+ existing: &serde_json::Value,
246
+ launched_key: &str,
247
+ launched: &mut serde_json::Value,
248
+ ) {
249
+ let Some(obj) = launched.as_object_mut() else {
250
+ return;
251
+ };
252
+ let existing_team = existing
253
+ .get("teams")
254
+ .and_then(serde_json::Value::as_object)
255
+ .and_then(|teams| teams.get(launched_key))
256
+ .unwrap_or(existing);
257
+ for key in ["is_external_leader", "leader_client"] {
258
+ if !obj.contains_key(key) {
259
+ if let Some(value) = existing_team.get(key).or_else(|| existing.get(key)) {
260
+ obj.insert(key.to_string(), value.clone());
261
+ }
262
+ }
263
+ }
264
+ }
265
+
266
+ pub(super) fn drop_bare_worker_seeded_owner(launched: &mut serde_json::Value, launched_key: &str) {
267
+ if has_positive_caller_leader_env() {
268
+ return;
269
+ }
270
+ let pane = launched
271
+ .get("team_owner")
272
+ .and_then(|owner| owner.get("pane_id"))
273
+ .and_then(serde_json::Value::as_str)
274
+ .unwrap_or("");
275
+ if pane.ends_with("-first") {
276
+ seed_unbound_launched_owner(launched, launched_key);
277
+ }
278
+ }
279
+
280
+ pub(super) fn merge_workspace_team_state_with_key(
281
+ existing: &serde_json::Value,
282
+ launched: &serde_json::Value,
283
+ launched_key: &str,
284
+ ) -> serde_json::Value {
285
+ let mut launched_obj = launched.as_object().cloned().unwrap_or_default();
286
+ let mut teams = existing
287
+ .get("teams")
288
+ .and_then(serde_json::Value::as_object)
289
+ .cloned()
290
+ .unwrap_or_default();
291
+ let launched_entry = crate::state::projection::compact_team_state(launched);
292
+ if !existing
293
+ .get("session_name")
294
+ .and_then(serde_json::Value::as_str)
295
+ .is_some_and(|session| !session.is_empty())
296
+ {
297
+ teams.insert(launched_key.to_string(), launched_entry);
298
+ launched_obj.insert("teams".to_string(), serde_json::Value::Object(teams));
299
+ return serde_json::Value::Object(launched_obj);
300
+ }
301
+
302
+ let existing_key = explicit_active_team_key(existing)
303
+ .unwrap_or_else(|| crate::state::projection::team_state_key(existing));
304
+ if existing_key == launched_key {
305
+ let mut teams = existing
306
+ .get("teams")
307
+ .and_then(serde_json::Value::as_object)
308
+ .cloned()
309
+ .unwrap_or_default();
310
+ teams.insert(launched_key.to_string(), launched_entry);
311
+ launched_obj.insert("teams".to_string(), serde_json::Value::Object(teams));
312
+ return serde_json::Value::Object(launched_obj);
313
+ }
314
+
315
+ let mut merged = existing.as_object().cloned().unwrap_or_default();
316
+ let mut teams = merged
317
+ .get("teams")
318
+ .and_then(serde_json::Value::as_object)
319
+ .cloned()
320
+ .unwrap_or_default();
321
+ teams
322
+ .entry(existing_key)
323
+ .or_insert_with(|| crate::state::projection::compact_team_state(existing));
324
+ teams.insert(launched_key.to_string(), launched_entry);
325
+ merged.insert("teams".to_string(), serde_json::Value::Object(teams));
326
+ serde_json::Value::Object(merged)
327
+ }
328
+
329
+ #[cfg(test)]
330
+ mod merge_workspace_team_state_with_key_tests {
331
+ use super::*;
332
+ use serde_json::json;
333
+
334
+ #[test]
335
+ fn empty_top_level_existing_session_preserves_existing_teams() {
336
+ let existing = json!({
337
+ "session_name": "",
338
+ "active_team_key": "parent",
339
+ "teams": {
340
+ "parent": {
341
+ "session_name": "team-parent",
342
+ "agents": {"parent_worker": {"status": "running"}}
343
+ }
344
+ }
345
+ });
346
+ let launched = json!({
347
+ "session_name": "team-child",
348
+ "agents": {"child_worker": {"status": "running"}}
349
+ });
350
+ let merged = merge_workspace_team_state_with_key(&existing, &launched, "child");
351
+ assert_eq!(
352
+ merged.pointer("/teams/parent/session_name"),
353
+ Some(&json!("team-parent")),
354
+ "existing.teams must survive even when existing.session_name is empty: {merged}"
355
+ );
356
+ assert_eq!(
357
+ merged.pointer("/teams/child/session_name"),
358
+ Some(&json!("team-child")),
359
+ "launched team must still be inserted under its runtime key: {merged}"
360
+ );
361
+ }
362
+ }
363
+
364
+ pub(super) fn promote_launched_binding_from_team_entry(
365
+ launched: &mut serde_json::Value,
366
+ launched_key: &str,
367
+ ) {
368
+ let entry = launched
369
+ .get("teams")
370
+ .and_then(|teams| teams.get(launched_key))
371
+ .cloned();
372
+ let Some(entry) = entry else {
373
+ return;
374
+ };
375
+ let Some(obj) = launched.as_object_mut() else {
376
+ return;
377
+ };
378
+ for key in ["leader_receiver", "team_owner", "owner_epoch"] {
379
+ if !obj.contains_key(key) {
380
+ if let Some(value) = entry.get(key) {
381
+ obj.insert(key.to_string(), value.clone());
382
+ }
383
+ }
384
+ }
385
+ }
386
+
387
+ pub(super) fn drop_unbound_top_level_owner(state: &mut serde_json::Value) {
388
+ let pane = state
389
+ .get("team_owner")
390
+ .and_then(|owner| owner.get("pane_id"))
391
+ .and_then(serde_json::Value::as_str)
392
+ .unwrap_or("");
393
+ if pane.starts_with('%') || pane.chars().all(|ch| ch.is_ascii_digit()) && !pane.is_empty() {
394
+ return;
395
+ }
396
+ if let Some(obj) = state.as_object_mut() {
397
+ obj.remove("leader_receiver");
398
+ obj.remove("team_owner");
399
+ obj.remove("owner_epoch");
400
+ }
401
+ }
402
+
403
+ pub(super) fn drop_foreign_seeded_owner(
404
+ existing: &serde_json::Value,
405
+ launched_key: &str,
406
+ launched: &mut serde_json::Value,
407
+ ) {
408
+ let Some(pane) = launched
409
+ .get("team_owner")
410
+ .and_then(|owner| owner.get("pane_id"))
411
+ .and_then(serde_json::Value::as_str)
412
+ .filter(|pane| !pane.is_empty())
413
+ else {
414
+ return;
415
+ };
416
+ if owner_pane_belongs_to_other_team(existing, launched_key, pane) {
417
+ let replacement = unbound_launched_owner(launched, launched_key);
418
+ // Stage 3a (identity-boundary unified plan, architect direction
419
+ // 2026-06-23): route owner replace through ownership repository.
420
+ // Both branches (Some owner replacement; None = removal) preserve
421
+ // pre-3a behaviour: write_owner overwrites the top-level fields
422
+ // when given; the None branch falls back to direct mutation (the
423
+ // repository has no remove API yet — removal is uncommon enough
424
+ // to leave it inline).
425
+ if let Some(owner) = replacement {
426
+ let record = crate::state::ownership::OwnershipWrite::new().with_team_owner(owner);
427
+ crate::state::ownership::write_owner(launched, launched_key, record);
428
+ if let Some(obj) = launched.as_object_mut() {
429
+ obj.remove("owner_epoch");
430
+ }
431
+ } else if let Some(obj) = launched.as_object_mut() {
432
+ obj.remove("team_owner");
433
+ obj.remove("owner_epoch");
434
+ }
435
+ }
436
+ }
437
+
438
+ pub(super) fn drop_worker_pane_seeded_owner(
439
+ launched: &mut serde_json::Value,
440
+ launched_key: &str,
441
+ started: &[StartedAgent],
442
+ worker_tmux_socket: Option<&str>,
443
+ ) {
444
+ let Some(pane) = launched
445
+ .get("team_owner")
446
+ .and_then(|owner| owner.get("pane_id"))
447
+ .and_then(serde_json::Value::as_str)
448
+ .filter(|pane| !pane.is_empty())
449
+ else {
450
+ return;
451
+ };
452
+ let leader_pane = std::env::var("TEAM_AGENT_LEADER_PANE_ID")
453
+ .ok()
454
+ .filter(|value| !value.is_empty());
455
+ let tmux_pane = std::env::var("TMUX_PANE")
456
+ .ok()
457
+ .filter(|value| !value.is_empty());
458
+ let has_leader_identity_env = has_positive_caller_leader_env();
459
+ let seeded_from_bare_tmux = !has_leader_identity_env && tmux_pane.as_deref() == Some(pane);
460
+ let caller_tmux_socket = crate::tmux_backend::socket_name_from_tmux_env();
461
+ if seeded_from_bare_tmux
462
+ && (tmux_sockets_match_or_unknown(caller_tmux_socket.as_deref(), worker_tmux_socket)
463
+ || pane.ends_with("-first"))
464
+ && seeded_pane_looks_like_worker(pane, started)
465
+ {
466
+ seed_unbound_launched_owner(launched, launched_key);
467
+ }
468
+ }
469
+
470
+ pub(super) fn seeded_pane_looks_like_worker(pane: &str, started: &[StartedAgent]) -> bool {
471
+ pane.ends_with("-first")
472
+ || started.iter().any(|agent| {
473
+ pane == agent.target
474
+ || pane.starts_with(agent.target.as_str())
475
+ || agent.target.starts_with(pane)
476
+ })
477
+ }
478
+
479
+ pub(super) fn launched_worker_tmux_socket(
480
+ transport: &dyn Transport,
481
+ workspace: &Path,
482
+ ) -> Option<String> {
483
+ if matches!(transport.kind(), crate::transport::BackendKind::Tmux) {
484
+ Some(crate::tmux_backend::socket_name_for_workspace(workspace))
485
+ } else {
486
+ None
487
+ }
488
+ }
489
+
490
+ pub(super) fn tmux_sockets_match_or_unknown(
491
+ caller_socket: Option<&str>,
492
+ worker_socket: Option<&str>,
493
+ ) -> bool {
494
+ match (caller_socket, worker_socket) {
495
+ (Some(caller), Some(worker)) => caller == worker,
496
+ (Some(_), None) => false,
497
+ (None, _) => true,
498
+ }
499
+ }