@team-agent/installer 0.3.14 → 0.3.16

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 (46) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +1 -0
  4. package/crates/team-agent/src/cli/emit.rs +92 -11
  5. package/crates/team-agent/src/cli/leader.rs +2 -0
  6. package/crates/team-agent/src/cli/mod.rs +255 -37
  7. package/crates/team-agent/src/cli/send.rs +252 -0
  8. package/crates/team-agent/src/cli/status.rs +5 -1
  9. package/crates/team-agent/src/cli/status_port.rs +24 -0
  10. package/crates/team-agent/src/cli/tests/base.rs +21 -3
  11. package/crates/team-agent/src/cli/tests/divergence.rs +6 -6
  12. package/crates/team-agent/src/cli/tests/run_delegation.rs +15 -3
  13. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +285 -2
  14. package/crates/team-agent/src/cli/tests/status_send.rs +25 -0
  15. package/crates/team-agent/src/cli/types.rs +32 -1
  16. package/crates/team-agent/src/compiler/tests.rs +2 -2
  17. package/crates/team-agent/src/compiler.rs +1 -1
  18. package/crates/team-agent/src/fake_worker.rs +32 -145
  19. package/crates/team-agent/src/leader/start.rs +272 -8
  20. package/crates/team-agent/src/leader/tests/basics.rs +1 -0
  21. package/crates/team-agent/src/leader/tests/identity.rs +121 -25
  22. package/crates/team-agent/src/leader/types.rs +9 -0
  23. package/crates/team-agent/src/lifecycle/display.rs +3 -3
  24. package/crates/team-agent/src/lifecycle/launch.rs +631 -70
  25. package/crates/team-agent/src/lifecycle/restart/agent.rs +137 -17
  26. package/crates/team-agent/src/lifecycle/restart/common.rs +38 -2
  27. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +79 -4
  28. package/crates/team-agent/src/lifecycle/tests/core.rs +14 -5
  29. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +731 -8
  30. package/crates/team-agent/src/lifecycle/types.rs +5 -0
  31. package/crates/team-agent/src/lifecycle/worker_command_context.rs +8 -0
  32. package/crates/team-agent/src/mcp_server/wire.rs +153 -3
  33. package/crates/team-agent/src/messaging/leader_receiver.rs +276 -43
  34. package/crates/team-agent/src/messaging/mod.rs +6 -3
  35. package/crates/team-agent/src/messaging/results.rs +240 -158
  36. package/crates/team-agent/src/messaging/send.rs +3 -2
  37. package/crates/team-agent/src/messaging/tests/e23.rs +35 -0
  38. package/crates/team-agent/src/messaging/tests/mod.rs +6 -2
  39. package/crates/team-agent/src/messaging/tests/runtime.rs +9 -9
  40. package/crates/team-agent/src/os_probe.rs +11 -0
  41. package/crates/team-agent/src/state/persist.rs +6 -0
  42. package/crates/team-agent/src/tmux_backend.rs +85 -0
  43. package/crates/team-agent/src/transport/test_support.rs +46 -1
  44. package/crates/team-agent/src/transport.rs +27 -0
  45. package/package.json +4 -4
  46. package/skills/team-agent/references/recovery-runbook.md +277 -0
@@ -107,7 +107,10 @@ fn parse_rendered_header(line: &str) -> Option<RenderedBlock> {
107
107
  .split_once(" for ")
108
108
  .map(|(_, task)| task.trim().to_string())
109
109
  .filter(|task| !task.is_empty());
110
- Some(RenderedBlock { task_id, lines: Vec::new() })
110
+ Some(RenderedBlock {
111
+ task_id,
112
+ lines: Vec::new(),
113
+ })
111
114
  }
112
115
 
113
116
  fn parse_token(line: &str) -> Option<String> {
@@ -135,149 +138,13 @@ fn report_fake_result(
135
138
  .ok()
136
139
  .map(|value| value.trim().to_string())
137
140
  .filter(|value| !value.is_empty());
138
- mirror_fake_result_to_leader(workspace, owner_team.as_deref(), &envelope, source_content);
141
+ let _ = source_content;
139
142
  crate::messaging::report_result_for_owner_team(workspace, &envelope, owner_team.as_deref())
140
143
  .map_err(|e| FakeWorkerError::Report(e.to_string()))?;
141
- mirror_fake_result_to_leader(workspace, owner_team.as_deref(), &envelope, source_content);
142
144
  writeln!(output, "{}", serde_json::to_string(&envelope)?)?;
143
145
  Ok(())
144
146
  }
145
147
 
146
- fn mirror_fake_result_to_leader(
147
- workspace: &Path,
148
- owner_team: Option<&str>,
149
- envelope: &serde_json::Value,
150
- source_content: Option<&str>,
151
- ) {
152
- let Ok(raw_state) = crate::state::persist::load_runtime_state(workspace) else {
153
- return;
154
- };
155
- let state = owner_team
156
- .and_then(|team| crate::state::projection::resolve_owner_team_id(&raw_state, team).canonical_key().map(str::to_string))
157
- .map(|team| crate::state::projection::project_top_level_view(&raw_state, &team))
158
- .unwrap_or(raw_state);
159
- let attached = state
160
- .get("leader_receiver")
161
- .and_then(|receiver| receiver.get("status"))
162
- .and_then(serde_json::Value::as_str)
163
- == Some("attached");
164
- let pane_id = state
165
- .get("leader_receiver")
166
- .and_then(|receiver| receiver.get("pane_id"))
167
- .and_then(serde_json::Value::as_str)
168
- .filter(|pane| !pane.is_empty())
169
- .map(str::to_string)
170
- .or_else(|| owner_team.and_then(|team| claimed_pane_from_events(workspace, team)));
171
- let Some(pane_id) = pane_id.filter(|_| attached || owner_team.is_some()) else {
172
- return;
173
- };
174
- let summary = envelope
175
- .get("summary")
176
- .and_then(serde_json::Value::as_str)
177
- .unwrap_or("Fake worker completed");
178
- let result_id = envelope
179
- .get("result_id")
180
- .and_then(serde_json::Value::as_str)
181
- .unwrap_or("");
182
- let text = match source_content.filter(|content| !content.trim().is_empty()) {
183
- Some(content) => format!(
184
- "Fake worker result: {summary}\nMessage content: {}\nResult id: {result_id}",
185
- content.trim()
186
- ),
187
- None => format!("Fake worker result: {summary}\nResult id: {result_id}"),
188
- };
189
- let target = crate::transport::Target::Pane(crate::transport::PaneId::new(&pane_id));
190
- let payload = crate::transport::InjectPayload::Text(text.clone());
191
- let key = crate::transport::Key::Enter;
192
- if let Some(socket) = leader_receiver_full_socket(&state) {
193
- let endpoint_backend = crate::tmux_backend::TmuxBackend::for_tmux_endpoint(socket);
194
- if crate::transport::Transport::inject(&endpoint_backend, &target, &payload, key, true).is_ok() {
195
- mirror_to_team_session_panes(&state, workspace, agent_id_from_envelope(envelope), &text);
196
- return;
197
- }
198
- }
199
- let workspace_backend = crate::tmux_backend::TmuxBackend::for_workspace(workspace);
200
- let _ = crate::transport::Transport::inject(&workspace_backend, &target, &payload, key, true);
201
- mirror_to_team_session_panes(&state, workspace, agent_id_from_envelope(envelope), &text);
202
- }
203
-
204
- fn leader_receiver_full_socket(state: &serde_json::Value) -> Option<&str> {
205
- state
206
- .get("leader_receiver")
207
- .and_then(|receiver| receiver.get("tmux_socket"))
208
- .and_then(serde_json::Value::as_str)
209
- .filter(|socket| !socket.is_empty() && Path::new(socket).is_absolute())
210
- }
211
-
212
- fn claimed_pane_from_events(workspace: &Path, owner_team: &str) -> Option<String> {
213
- crate::event_log::EventLog::new(workspace)
214
- .tail(50)
215
- .ok()?
216
- .into_iter()
217
- .rev()
218
- .find_map(|event| {
219
- if event.get("event").and_then(serde_json::Value::as_str)
220
- != Some("leader_receiver.rebind_applied")
221
- {
222
- return None;
223
- }
224
- if event.get("team_id").and_then(serde_json::Value::as_str) != Some(owner_team) {
225
- return None;
226
- }
227
- event
228
- .get("new_pane_id")
229
- .and_then(serde_json::Value::as_str)
230
- .filter(|pane| !pane.is_empty())
231
- .map(str::to_string)
232
- })
233
- }
234
-
235
- fn agent_id_from_envelope(envelope: &serde_json::Value) -> Option<&str> {
236
- envelope.get("agent_id").and_then(serde_json::Value::as_str)
237
- }
238
-
239
- fn mirror_to_team_session_panes(
240
- state: &serde_json::Value,
241
- workspace: &Path,
242
- agent_id: Option<&str>,
243
- text: &str,
244
- ) {
245
- let Some(session) = state
246
- .get("session_name")
247
- .and_then(serde_json::Value::as_str)
248
- .filter(|session| !session.is_empty())
249
- else {
250
- return;
251
- };
252
- let worker_pane = agent_id.and_then(|agent_id| {
253
- state
254
- .get("agents")
255
- .and_then(|agents| agents.get(agent_id))
256
- .and_then(|agent| agent.get("pane_id"))
257
- .and_then(serde_json::Value::as_str)
258
- });
259
- if let Some(socket) = leader_receiver_full_socket(state) {
260
- let backend = crate::tmux_backend::TmuxBackend::for_tmux_endpoint(socket);
261
- for pane in crate::transport::Transport::list_targets(&backend).unwrap_or_default() {
262
- if pane.session.as_str() != session || Some(pane.pane_id.as_str()) == worker_pane {
263
- continue;
264
- }
265
- let target = crate::transport::Target::Pane(pane.pane_id);
266
- let payload = crate::transport::InjectPayload::Text(text.to_string());
267
- let _ = crate::transport::Transport::inject(&backend, &target, &payload, crate::transport::Key::Enter, true);
268
- }
269
- }
270
- let backend = crate::tmux_backend::TmuxBackend::for_workspace(workspace);
271
- for pane in crate::transport::Transport::list_targets(&backend).unwrap_or_default() {
272
- if pane.session.as_str() != session || Some(pane.pane_id.as_str()) == worker_pane {
273
- continue;
274
- }
275
- let target = crate::transport::Target::Pane(pane.pane_id);
276
- let payload = crate::transport::InjectPayload::Text(text.to_string());
277
- let _ = crate::transport::Transport::inject(&backend, &target, &payload, crate::transport::Key::Enter, true);
278
- }
279
- }
280
-
281
148
  fn fake_envelope(
282
149
  workspace: &Path,
283
150
  agent_id: &str,
@@ -331,7 +198,9 @@ mod tests {
331
198
  fn stored_result(ws: &std::path::Path) -> (String, String, String, serde_json::Value) {
332
199
  let store = MessageStore::open(ws).unwrap();
333
200
  let conn = open_db(store.db_path()).unwrap();
334
- let mut stmt = conn.prepare("select task_id, agent_id, status, envelope from results").unwrap();
201
+ let mut stmt = conn
202
+ .prepare("select task_id, agent_id, status, envelope from results")
203
+ .unwrap();
335
204
  let row = stmt
336
205
  .query_row([], |r| {
337
206
  Ok((
@@ -354,9 +223,18 @@ mod tests {
354
223
  ("t1", "w1", "success"),
355
224
  "stored result row must be task_id=t1 agent_id=w1 status=success"
356
225
  );
357
- assert_eq!(env["schema_version"], serde_json::json!("result_envelope_v1"));
358
- assert_eq!(env["summary"], serde_json::json!("Fake worker handled message m1"));
359
- assert_eq!(env["tests"], serde_json::json!([{"command": "fake-provider", "status": "passed"}]));
226
+ assert_eq!(
227
+ env["schema_version"],
228
+ serde_json::json!("result_envelope_v1")
229
+ );
230
+ assert_eq!(
231
+ env["summary"],
232
+ serde_json::json!("Fake worker handled message m1")
233
+ );
234
+ assert_eq!(
235
+ env["tests"],
236
+ serde_json::json!([{"command": "fake-provider", "status": "passed"}])
237
+ );
360
238
  let artifact_path = env["artifacts"][0]["path"].as_str().unwrap_or_default();
361
239
  assert!(
362
240
  artifact_path.ends_with(".team/logs/w1.scrollback"),
@@ -375,8 +253,14 @@ mod tests {
375
253
 
376
254
  assert_golden_envelope(&ws);
377
255
  let printed = String::from_utf8(out).unwrap();
378
- assert!(printed.contains("TEAM_AGENT_FAKE_READY agent=w1"), "must print the READY idle marker; got {printed}");
379
- assert!(printed.contains("TEAM_AGENT_FAKE_WORKING agent=w1"), "must print the WORKING marker on a non-empty line; got {printed}");
256
+ assert!(
257
+ printed.contains("TEAM_AGENT_FAKE_READY agent=w1"),
258
+ "must print the READY idle marker; got {printed}"
259
+ );
260
+ assert!(
261
+ printed.contains("TEAM_AGENT_FAKE_WORKING agent=w1"),
262
+ "must print the WORKING marker on a non-empty line; got {printed}"
263
+ );
380
264
  }
381
265
 
382
266
  // Rendered-message form: a `Team Agent message from leader for t1:` block ending `[team-agent-token:m1]`
@@ -391,6 +275,9 @@ mod tests {
391
275
 
392
276
  assert_golden_envelope(&ws);
393
277
  let printed = String::from_utf8(out).unwrap();
394
- assert!(printed.contains("TEAM_AGENT_FAKE_WORKING agent=w1"), "rendered form prints WORKING per line; got {printed}");
278
+ assert!(
279
+ printed.contains("TEAM_AGENT_FAKE_WORKING agent=w1"),
280
+ "rendered form prints WORKING per line; got {printed}"
281
+ );
395
282
  }
396
283
  }
@@ -7,7 +7,7 @@ use std::process::{Command, Stdio};
7
7
 
8
8
  use crate::provider::{get_adapter, Provider};
9
9
  use crate::tmux_backend::TmuxBackend;
10
- use crate::transport::{PaneId, SessionName, Target, Transport};
10
+ use crate::transport::{PaneId, SessionName, SpawnResult, Target, Transport, WindowName};
11
11
 
12
12
  use super::helpers::{
13
13
  provider_wire, resolve_workspace_for_hash, sanitize_session_folder, sha1_hex_prefix,
@@ -29,6 +29,7 @@ pub fn leader_start_plan(
29
29
  attach_existing: bool,
30
30
  confirm_attach: bool,
31
31
  attach_session: Option<&SessionName>,
32
+ external_leader: bool,
32
33
  ) -> Result<LeaderStartPlan, LeaderError> {
33
34
  if attach_session.is_some() && !confirm_attach {
34
35
  return Err(LeaderError::Start(
@@ -49,15 +50,24 @@ pub fn leader_start_plan(
49
50
  command_name
50
51
  )));
51
52
  }
52
- let identity = leader_identity_context(workspace, None, None)?;
53
- let session_name = attach_session
54
- .cloned()
55
- .or_else(|| Some(leader_session_name(provider, workspace)));
53
+ let state = crate::state::persist::load_runtime_state(workspace).ok();
54
+ let identity = leader_identity_context(workspace, None, state.as_ref())?;
55
+ let external_path = external_leader || attach_existing || attach_session.is_some();
56
+ let session_name = if external_path {
57
+ attach_session
58
+ .cloned()
59
+ .or_else(|| Some(leader_session_name(provider, workspace)))
60
+ } else {
61
+ Some(managed_team_session_name(&identity))
62
+ };
56
63
  let in_tmux = std::env::var_os("TMUX").is_some();
57
- if !in_tmux {
64
+ if !in_tmux || !external_path {
58
65
  ensure_tmux_installed()?;
59
66
  }
60
- let existing_session = if !in_tmux && !attach_existing && attach_session.is_none() {
67
+ if !external_path {
68
+ validate_managed_tmux_context(workspace)?;
69
+ }
70
+ let existing_session = if external_path && !in_tmux && !attach_existing && attach_session.is_none() {
61
71
  match session_name.as_ref() {
62
72
  Some(session) => tmux_session_exists(workspace, session)?,
63
73
  None => false,
@@ -65,7 +75,9 @@ pub fn leader_start_plan(
65
75
  } else {
66
76
  false
67
77
  };
68
- let mode = if in_tmux {
78
+ let mode = if !external_path {
79
+ LeaderStartMode::ManagedTmuxClient
80
+ } else if in_tmux {
69
81
  LeaderStartMode::ExecProvider
70
82
  } else if attach_existing || attach_session.is_some() || existing_session {
71
83
  LeaderStartMode::AttachExisting
@@ -86,6 +98,7 @@ pub fn leader_start_plan(
86
98
  } else {
87
99
  leader_env.clone()
88
100
  };
101
+ let provider_argv = provider_command_argv(provider, provider_args);
89
102
  Ok(LeaderStartPlan {
90
103
  mode,
91
104
  provider,
@@ -93,6 +106,10 @@ pub fn leader_start_plan(
93
106
  socket: LeaderLaunchSocket::Workspace,
94
107
  session_name,
95
108
  argv,
109
+ provider_argv,
110
+ leader_window: (mode == LeaderStartMode::ManagedTmuxClient)
111
+ .then(|| WindowName::new("leader")),
112
+ is_external_leader: external_path,
96
113
  leader_env: plan_env,
97
114
  identity: Some(identity),
98
115
  detached: false,
@@ -142,6 +159,7 @@ pub fn start_leader(
142
159
  attach_existing: bool,
143
160
  confirm_attach: bool,
144
161
  attach_session: Option<&SessionName>,
162
+ external_leader: bool,
145
163
  ) -> Result<(), LeaderError> {
146
164
  let plan = leader_start_plan(
147
165
  provider,
@@ -150,6 +168,7 @@ pub fn start_leader(
150
168
  attach_existing,
151
169
  confirm_attach,
152
170
  attach_session,
171
+ external_leader,
153
172
  )?;
154
173
  crate::event_log::EventLog::new(workspace).write(
155
174
  super::LeaderEvent::LeaderStart.name(),
@@ -170,6 +189,9 @@ pub fn execute_leader_plan(
170
189
  plan: &LeaderStartPlan,
171
190
  workspace: &Path,
172
191
  ) -> Result<LeaderLaunchOutcome, LeaderError> {
192
+ if plan.mode == LeaderStartMode::ManagedTmuxClient {
193
+ return execute_managed_leader_plan(plan, workspace);
194
+ }
173
195
  let mut argv = plan.argv.clone();
174
196
  let detached = plan.mode == LeaderStartMode::NewTmuxSession
175
197
  && !std::io::stdin().is_terminal()
@@ -236,6 +258,12 @@ fn start_argv(
236
258
  argv.extend(provider_args.iter().cloned());
237
259
  Ok(argv)
238
260
  }
261
+ LeaderStartMode::ManagedTmuxClient => {
262
+ let Some(session) = session_name else {
263
+ return Err(LeaderError::Start("managed leader session missing".to_string()));
264
+ };
265
+ managed_client_argv(workspace, session)
266
+ }
239
267
  LeaderStartMode::AttachExisting => {
240
268
  let Some(session) = session_name else {
241
269
  return Err(LeaderError::Start("attach session missing".to_string()));
@@ -283,6 +311,224 @@ fn start_argv(
283
311
  }
284
312
  }
285
313
 
314
+ fn provider_command_argv(provider: Provider, provider_args: &[String]) -> Vec<String> {
315
+ let mut argv = vec![provider_command_name(provider).to_string()];
316
+ argv.extend(provider_args.iter().cloned());
317
+ argv
318
+ }
319
+
320
+ fn managed_team_session_name(identity: &LeaderIdentity) -> SessionName {
321
+ let team = identity.team_id.as_str();
322
+ if team.starts_with("team-") {
323
+ SessionName::new(team.to_string())
324
+ } else {
325
+ SessionName::new(format!("team-{team}"))
326
+ }
327
+ }
328
+
329
+ fn managed_client_argv(workspace: &Path, session: &SessionName) -> Result<Vec<String>, LeaderError> {
330
+ let target = format!("{}:leader", session.as_str());
331
+ let argv = if std::env::var_os("TMUX").is_some() {
332
+ vec![
333
+ "tmux".to_string(),
334
+ "switch-client".to_string(),
335
+ "-t".to_string(),
336
+ target,
337
+ ]
338
+ } else {
339
+ vec![
340
+ "tmux".to_string(),
341
+ "attach-session".to_string(),
342
+ "-t".to_string(),
343
+ target,
344
+ ]
345
+ };
346
+ Ok(TmuxBackend::argv_for_workspace(workspace, &argv))
347
+ }
348
+
349
+ fn validate_managed_tmux_context(workspace: &Path) -> Result<(), LeaderError> {
350
+ let Some(current_endpoint) = crate::tmux_backend::socket_name_from_tmux_env() else {
351
+ return Ok(());
352
+ };
353
+ let expected = crate::tmux_backend::socket_name_for_workspace(workspace);
354
+ if tmux_endpoint_matches_socket_name(&current_endpoint, &expected) {
355
+ return Ok(());
356
+ }
357
+ Err(LeaderError::Start(format!(
358
+ "Error: managed leader launch refused inside a different tmux server\nReason: current TMUX socket is {current_endpoint}, but this team uses {expected}\nAction: detach and run from a normal shell, or use --external-leader for the external leader topology"
359
+ )))
360
+ }
361
+
362
+ fn tmux_endpoint_matches_socket_name(endpoint: &str, socket_name: &str) -> bool {
363
+ endpoint == socket_name
364
+ || Path::new(endpoint)
365
+ .file_name()
366
+ .and_then(|name| name.to_str())
367
+ == Some(socket_name)
368
+ }
369
+
370
+ fn execute_managed_leader_plan(
371
+ plan: &LeaderStartPlan,
372
+ workspace: &Path,
373
+ ) -> Result<LeaderLaunchOutcome, LeaderError> {
374
+ let Some(session) = plan.session_name.as_ref() else {
375
+ return Err(LeaderError::Start("managed leader session missing".to_string()));
376
+ };
377
+ let Some(window) = plan.leader_window.as_ref() else {
378
+ return Err(LeaderError::Start("managed leader window missing".to_string()));
379
+ };
380
+ let transport = TmuxBackend::for_workspace(workspace);
381
+ let spawned = ensure_managed_leader_pane(&transport, session, window, plan, workspace)?;
382
+ persist_managed_leader_binding(plan, workspace, &spawned)?;
383
+ spawn_managed_provider_startup_prompt_handler(
384
+ plan.provider,
385
+ workspace.to_path_buf(),
386
+ spawned.pane_id.as_str().to_string(),
387
+ );
388
+ let status = run_leader_argv(&plan.argv, &BTreeMap::new(), plan, workspace)?;
389
+ let code = status.code();
390
+ if !status.success() {
391
+ return Err(LeaderError::Start(format!(
392
+ "leader launcher exited with status {}",
393
+ code.map(|c| c.to_string())
394
+ .unwrap_or_else(|| "signal".to_string())
395
+ )));
396
+ }
397
+ Ok(LeaderLaunchOutcome {
398
+ status: LeaderLaunchStatus::Exited,
399
+ exit_code: code,
400
+ session_name: plan.session_name.clone(),
401
+ reason: None,
402
+ })
403
+ }
404
+
405
+ fn ensure_managed_leader_pane(
406
+ transport: &TmuxBackend,
407
+ session: &SessionName,
408
+ window: &WindowName,
409
+ plan: &LeaderStartPlan,
410
+ workspace: &Path,
411
+ ) -> Result<SpawnResult, LeaderError> {
412
+ if transport.has_session(session).unwrap_or(false) {
413
+ if transport
414
+ .list_windows(session)
415
+ .unwrap_or_default()
416
+ .iter()
417
+ .any(|existing| existing.as_str() == window.as_str())
418
+ {
419
+ if let Some(existing) = transport
420
+ .list_targets()
421
+ .unwrap_or_default()
422
+ .into_iter()
423
+ .find(|pane| {
424
+ pane.session.as_str() == session.as_str()
425
+ && pane.window_name.as_ref().map(WindowName::as_str)
426
+ == Some(window.as_str())
427
+ })
428
+ {
429
+ return Ok(SpawnResult {
430
+ pane_id: existing.pane_id,
431
+ session: session.clone(),
432
+ window: window.clone(),
433
+ child_pid: existing.pane_pid,
434
+ });
435
+ }
436
+ }
437
+ transport
438
+ .spawn_into(session, window, &plan.provider_argv, workspace, &plan.leader_env)
439
+ .map_err(|error| LeaderError::Start(error.to_string()))
440
+ } else {
441
+ transport
442
+ .spawn_first(session, window, &plan.provider_argv, workspace, &plan.leader_env)
443
+ .map_err(|error| LeaderError::Start(error.to_string()))
444
+ }
445
+ }
446
+
447
+ fn persist_managed_leader_binding(
448
+ plan: &LeaderStartPlan,
449
+ workspace: &Path,
450
+ spawned: &SpawnResult,
451
+ ) -> Result<(), LeaderError> {
452
+ let identity = plan
453
+ .identity
454
+ .as_ref()
455
+ .ok_or_else(|| LeaderError::Start("managed leader identity missing".to_string()))?;
456
+ let mut state = crate::state::persist::load_runtime_state(workspace)
457
+ .unwrap_or_else(|_| serde_json::json!({}));
458
+ let owner_epoch = state
459
+ .get("owner_epoch")
460
+ .and_then(serde_json::Value::as_u64)
461
+ .or_else(|| {
462
+ state
463
+ .get("team_owner")
464
+ .and_then(|owner| owner.get("owner_epoch"))
465
+ .and_then(serde_json::Value::as_u64)
466
+ })
467
+ .unwrap_or(0)
468
+ .saturating_add(1);
469
+ let now = chrono::Utc::now().to_rfc3339();
470
+ let socket = crate::tmux_backend::socket_name_for_workspace(workspace);
471
+ let provider = serde_json::to_value(plan.provider)?;
472
+ let session = spawned.session.as_str().to_string();
473
+ let window = spawned.window.as_str().to_string();
474
+ let pane = spawned.pane_id.as_str().to_string();
475
+ let receiver = serde_json::json!({
476
+ "mode": "direct_tmux",
477
+ "status": "attached",
478
+ "provider": provider.clone(),
479
+ "pane_id": pane,
480
+ "pane": pane,
481
+ "session_name": session,
482
+ "window_name": window,
483
+ "tmux_socket": socket,
484
+ "leader_session_uuid": identity.leader_session_uuid,
485
+ "owner_epoch": owner_epoch,
486
+ "attached_at": now,
487
+ "discovery": "managed_launcher",
488
+ });
489
+ let owner = serde_json::json!({
490
+ "pane_id": pane,
491
+ "provider": provider.clone(),
492
+ "machine_fingerprint": identity.machine_fingerprint,
493
+ "leader_session_uuid": identity.leader_session_uuid,
494
+ "owner_epoch": owner_epoch,
495
+ "claimed_at": now,
496
+ "claimed_via": "claim-leader",
497
+ "os_user": identity.os_user,
498
+ });
499
+ if let Some(obj) = state.as_object_mut() {
500
+ obj.insert("session_name".to_string(), serde_json::json!(session));
501
+ obj.insert(
502
+ "active_team_key".to_string(),
503
+ serde_json::json!(identity.team_id.as_str()),
504
+ );
505
+ obj.insert("tmux_socket".to_string(), serde_json::json!(socket));
506
+ obj.insert("is_external_leader".to_string(), serde_json::json!(false));
507
+ obj.insert(
508
+ "leader_client".to_string(),
509
+ serde_json::json!({
510
+ "diagnostic_only": true,
511
+ "attach_mode": if std::env::var_os("TMUX").is_some() { "switch-client" } else { "attach-session" },
512
+ "tmux": std::env::var("TMUX").ok(),
513
+ }),
514
+ );
515
+ obj.insert("leader_receiver".to_string(), receiver);
516
+ obj.insert("team_owner".to_string(), owner);
517
+ obj.insert("owner_epoch".to_string(), serde_json::json!(owner_epoch));
518
+ }
519
+ let entry = crate::state::projection::compact_team_state(&state);
520
+ if let Some(obj) = state.as_object_mut() {
521
+ let teams = obj
522
+ .entry("teams".to_string())
523
+ .or_insert_with(|| serde_json::json!({}));
524
+ if let Some(teams) = teams.as_object_mut() {
525
+ teams.insert(identity.team_id.as_str().to_string(), entry);
526
+ }
527
+ }
528
+ crate::state::persist::save_runtime_state(workspace, &state)?;
529
+ Ok(())
530
+ }
531
+
286
532
  fn insert_detach_flag(argv: &mut Vec<String>) -> bool {
287
533
  if argv.iter().any(|arg| arg == "-d") {
288
534
  return false;
@@ -342,6 +588,24 @@ fn spawn_exec_provider_startup_prompt_handler(provider: Provider, workspace: Pat
342
588
  });
343
589
  }
344
590
 
591
+ fn spawn_managed_provider_startup_prompt_handler(
592
+ provider: Provider,
593
+ workspace: PathBuf,
594
+ pane_id: String,
595
+ ) {
596
+ std::thread::spawn(move || {
597
+ let transport = TmuxBackend::for_workspace(&workspace);
598
+ let _ = handle_exec_provider_startup_prompts(
599
+ provider,
600
+ &workspace,
601
+ &pane_id,
602
+ &transport,
603
+ 30,
604
+ 0.5,
605
+ );
606
+ });
607
+ }
608
+
345
609
  fn tmux_transport_for_current_pane() -> TmuxBackend {
346
610
  crate::tmux_backend::socket_name_from_tmux_env()
347
611
  .map(|endpoint| TmuxBackend::for_tmux_endpoint(&endpoint))
@@ -94,6 +94,7 @@ use super::*;
94
94
  #[test]
95
95
  fn leader_start_mode_serializes_to_python_strings() {
96
96
  assert_eq!(serde_json::to_string(&LeaderStartMode::ExecProvider).unwrap(), "\"exec_provider\"");
97
+ assert_eq!(serde_json::to_string(&LeaderStartMode::ManagedTmuxClient).unwrap(), "\"managed_tmux_client\"");
97
98
  assert_eq!(serde_json::to_string(&LeaderStartMode::NewTmuxSession).unwrap(), "\"new_tmux_session\"");
98
99
  assert_eq!(serde_json::to_string(&LeaderStartMode::AttachExisting).unwrap(), "\"attach_existing\"");
99
100
  }