@team-agent/installer 0.5.68 → 0.5.69

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 (63) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/diagnose.rs +74 -0
  4. package/crates/team-agent/src/cli/emit.rs +5 -4
  5. package/crates/team-agent/src/cli/leader.rs +1 -0
  6. package/crates/team-agent/src/cli/spec.rs +1 -0
  7. package/crates/team-agent/src/compiler.rs +62 -5
  8. package/crates/team-agent/src/coordinator/runtime_detectors.rs +1 -0
  9. package/crates/team-agent/src/layout/worker_window_helpers.rs +1 -0
  10. package/crates/team-agent/src/leader/start.rs +53 -12
  11. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +67 -15
  12. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +18 -0
  13. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +1 -0
  14. package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +260 -44
  15. package/crates/team-agent/src/lifecycle/launch/cursor_mcp_iso.rs +120 -0
  16. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +5 -1
  17. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +10 -59
  18. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +830 -0
  19. package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +9 -3
  20. package/crates/team-agent/src/lifecycle/launch/spawn.rs +70 -28
  21. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +1 -0
  22. package/crates/team-agent/src/lifecycle/launch.rs +10 -2
  23. package/crates/team-agent/src/lifecycle/profile_launch.rs +3 -2
  24. package/crates/team-agent/src/lifecycle/profile_smoke.rs +1 -0
  25. package/crates/team-agent/src/lifecycle/restart/agent.rs +90 -160
  26. package/crates/team-agent/src/lifecycle/restart/common.rs +146 -28
  27. package/crates/team-agent/src/lifecycle/restart/remove.rs +37 -1
  28. package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +24 -3
  29. package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +195 -18
  30. package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +30 -12
  31. package/crates/team-agent/src/lifecycle/tests/fixtures/pi_list_models_g0.stdout.txt +222 -0
  32. package/crates/team-agent/src/lifecycle/tests/g1_silent_faces.rs +5 -2
  33. package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +35 -48
  34. package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +1 -1
  35. package/crates/team-agent/src/lifecycle/tests/pi_catalog_red.rs +97 -0
  36. package/crates/team-agent/src/lifecycle/tests/pi_command_red.rs +128 -0
  37. package/crates/team-agent/src/lifecycle/tests/pi_compiler_red.rs +207 -0
  38. package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +838 -0
  39. package/crates/team-agent/src/lifecycle/tests/pi_executable_mcp_red.rs +320 -0
  40. package/crates/team-agent/src/lifecycle/tests/pi_lifecycle_doctor_red.rs +98 -0
  41. package/crates/team-agent/src/lifecycle/tests/pi_session_delivery_red.rs +216 -0
  42. package/crates/team-agent/src/lifecycle/tests/restart_rebind_hotfix_252_red.rs +1 -0
  43. package/crates/team-agent/src/lifecycle/tests.rs +7 -0
  44. package/crates/team-agent/src/lifecycle/worker_command_context.rs +61 -4
  45. package/crates/team-agent/src/messaging/delivery.rs +36 -12
  46. package/crates/team-agent/src/model/enums.rs +3 -1
  47. package/crates/team-agent/src/model/permissions.rs +1 -1
  48. package/crates/team-agent/src/provider/adapter.rs +29 -3
  49. package/crates/team-agent/src/provider/adapters/cursor_agent.rs +5 -2
  50. package/crates/team-agent/src/provider/adapters/mod.rs +1 -0
  51. package/crates/team-agent/src/provider/adapters/pi.rs +152 -0
  52. package/crates/team-agent/src/provider/bypass_flags.rs +1 -0
  53. package/crates/team-agent/src/provider/classify.rs +2 -2
  54. package/crates/team-agent/src/provider/faults.rs +1 -1
  55. package/crates/team-agent/src/provider/session/context_fork.rs +2 -1
  56. package/crates/team-agent/src/provider/session_scan/common.rs +1 -0
  57. package/crates/team-agent/src/provider/session_scan/pi.rs +172 -0
  58. package/crates/team-agent/src/provider/session_scan.rs +4 -0
  59. package/crates/team-agent/src/provider/tests/adapter.rs +80 -0
  60. package/crates/team-agent/src/provider/wire.rs +7 -1
  61. package/crates/team-agent/src/tmux_backend.rs +52 -0
  62. package/package.json +4 -4
  63. package/skills/team-agent/SKILL.md +2 -4
@@ -0,0 +1,838 @@
1
+ use crate::cli::spec::{CommandKind, COMMAND_SPECS};
2
+ use crate::compiler::compile_role_agent;
3
+ use crate::lifecycle::launch::pi_mcp::parse_pi_leader_args;
4
+ use crate::mcp_server::TeamOrchestratorTools;
5
+ use crate::model::enums::{AuthMode, Provider, ProviderEffort};
6
+ use crate::model::ids::{AgentId, TeamKey};
7
+ use crate::model::yaml::Value;
8
+ use crate::provider::{get_adapter, ProviderCommandContext};
9
+ use crate::transport::test_support::OfflineTransport;
10
+ use crate::transport::{Transport, WindowName};
11
+ use serde_json::json;
12
+ use std::path::{Path, PathBuf};
13
+ use std::process::Command;
14
+
15
+ #[path = "../../../tests/support/hermetic.rs"]
16
+ mod hermetic_guard;
17
+ use hermetic_guard::HermeticTestEnv;
18
+
19
+ const PI_DUAL_ENTRY_CHILD: &str = "TEAM_AGENT_TEST_PI_DUAL_ENTRY_CHILD";
20
+ const PI_DUAL_ENTRY_PARENT_PID: &str = "TEAM_AGENT_TEST_PI_DUAL_ENTRY_PARENT_PID";
21
+ const PI_DUAL_ENTRY_TEST: &str = concat!(
22
+ "lifecycle::tests::pi_dual_entry_red::",
23
+ "pi_leader_and_teammate_share_provider_plan_but_are_separately_launchable"
24
+ );
25
+
26
+ fn run_process_isolated(marker: &str, test_name: &str, body: impl FnOnce()) {
27
+ if std::env::var_os(marker).is_some() {
28
+ body();
29
+ return;
30
+ }
31
+
32
+ let output = Command::new(std::env::current_exe().expect("current lib-test executable"))
33
+ .args(["--exact", test_name, "--nocapture", "--test-threads=1"])
34
+ .env(marker, "1")
35
+ .env(PI_DUAL_ENTRY_PARENT_PID, std::process::id().to_string())
36
+ .output()
37
+ .expect("run Pi send_message fixture in isolated child test process");
38
+ assert!(
39
+ output.status.success(),
40
+ "isolated Pi child test failed: test={test_name} status={:?}\nstdout:\n{}\nstderr:\n{}",
41
+ output.status.code(),
42
+ String::from_utf8_lossy(&output.stdout),
43
+ String::from_utf8_lossy(&output.stderr),
44
+ );
45
+ }
46
+
47
+ #[cfg(unix)]
48
+ const FILE_H_NATIVE_COMPILE_DEADLINE: std::time::Duration = std::time::Duration::from_secs(15);
49
+ #[cfg(unix)]
50
+ const FILE_H_NATIVE_READINESS_DEADLINE: std::time::Duration = std::time::Duration::from_secs(3);
51
+
52
+ #[cfg(unix)]
53
+ #[derive(Debug)]
54
+ struct FileHCommandTimeout {
55
+ stage: &'static str,
56
+ pid: u32,
57
+ deadline: std::time::Duration,
58
+ elapsed: std::time::Duration,
59
+ reaped_status: std::process::ExitStatus,
60
+ }
61
+
62
+ #[cfg(unix)]
63
+ fn run_file_h_command_with_deadline(
64
+ command: &mut std::process::Command,
65
+ stage: &'static str,
66
+ deadline: std::time::Duration,
67
+ stdout_path: &Path,
68
+ stderr_path: &Path,
69
+ ) -> Result<std::process::Output, FileHCommandTimeout> {
70
+ let stdout_file = std::fs::File::create(stdout_path)
71
+ .unwrap_or_else(|error| panic!("{stage}: create stdout receipt failed: {error}"));
72
+ let stderr_file = std::fs::File::create(stderr_path)
73
+ .unwrap_or_else(|error| panic!("{stage}: create stderr receipt failed: {error}"));
74
+ let mut child = command
75
+ .stdout(std::process::Stdio::from(stdout_file))
76
+ .stderr(std::process::Stdio::from(stderr_file))
77
+ .spawn()
78
+ .unwrap_or_else(|error| panic!("{stage}: spawn failed: {error}"));
79
+ let pid = child.id();
80
+ let started = std::time::Instant::now();
81
+ loop {
82
+ if let Some(status) = child
83
+ .try_wait()
84
+ .unwrap_or_else(|error| panic!("{stage}: poll pid {pid} failed: {error}"))
85
+ {
86
+ return Ok(std::process::Output {
87
+ status,
88
+ stdout: std::fs::read(stdout_path).unwrap_or_default(),
89
+ stderr: std::fs::read(stderr_path).unwrap_or_default(),
90
+ });
91
+ }
92
+ let elapsed = started.elapsed();
93
+ if elapsed >= deadline {
94
+ child
95
+ .kill()
96
+ .unwrap_or_else(|error| panic!("{stage}: kill exact pid {pid} failed: {error}"));
97
+ let reaped_status = child
98
+ .wait()
99
+ .unwrap_or_else(|error| panic!("{stage}: reap exact pid {pid} failed: {error}"));
100
+ return Err(FileHCommandTimeout {
101
+ stage,
102
+ pid,
103
+ deadline,
104
+ elapsed,
105
+ reaped_status,
106
+ });
107
+ }
108
+ std::thread::park_timeout(std::time::Duration::from_millis(10));
109
+ }
110
+ }
111
+
112
+ fn dynamic_add_fixture(root: &Path, label: &str, role_doc: &str) -> (PathBuf, PathBuf) {
113
+ let team = root.join(label);
114
+ std::fs::create_dir_all(team.join("agents")).expect("create dynamic team fixture");
115
+ std::fs::write(
116
+ team.join("TEAM.md"),
117
+ format!(
118
+ "---\nname: {label}\nobjective: Dynamic add fixture.\nprovider: fake\n---\n\nfixture\n"
119
+ ),
120
+ )
121
+ .expect("write dynamic TEAM.md");
122
+ std::fs::write(
123
+ team.join("agents/implementer.md"),
124
+ "---\nname: implementer\nrole: Existing Worker\nprovider: fake\nmodel: fake\nauth_mode: subscription\ndangerously_skip_permissions: false\ntools:\n - mcp_team\n---\n\nexisting\n",
125
+ )
126
+ .expect("write existing role");
127
+ let role = team.join("mate-role.md");
128
+ std::fs::write(&role, role_doc).expect("write dynamic role");
129
+ crate::state::persist::save_runtime_state(
130
+ &team,
131
+ &json!({
132
+ "session_name": format!("team-{label}"),
133
+ "active_team_key": label,
134
+ "team_dir": team,
135
+ "agents": {
136
+ "implementer": {
137
+ "status": "running",
138
+ "provider": "fake",
139
+ "role": "Existing Worker",
140
+ "model": "fake",
141
+ "auth_mode": "subscription",
142
+ "window": "implementer"
143
+ }
144
+ }
145
+ }),
146
+ )
147
+ .expect("seed dynamic runtime state");
148
+ super::launch_spawn::seed_healthy_coordinator(&team);
149
+ (team, role)
150
+ }
151
+
152
+ #[cfg(unix)]
153
+ struct EnvVarGuard {
154
+ key: &'static str,
155
+ previous: Option<std::ffi::OsString>,
156
+ }
157
+
158
+ #[cfg(unix)]
159
+ impl EnvVarGuard {
160
+ fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
161
+ let previous = std::env::var_os(key);
162
+ unsafe {
163
+ std::env::set_var(key, value);
164
+ }
165
+ Self { key, previous }
166
+ }
167
+ }
168
+
169
+ #[cfg(unix)]
170
+ impl Drop for EnvVarGuard {
171
+ fn drop(&mut self) {
172
+ unsafe {
173
+ if let Some(previous) = self.previous.take() {
174
+ std::env::set_var(self.key, previous);
175
+ } else {
176
+ std::env::remove_var(self.key);
177
+ }
178
+ }
179
+ }
180
+ }
181
+
182
+ #[test]
183
+ fn pi_leader_and_teammate_share_provider_plan_but_are_separately_launchable() {
184
+ run_process_isolated(PI_DUAL_ENTRY_CHILD, PI_DUAL_ENTRY_TEST, || {
185
+ let hermetic = HermeticTestEnv::enter("pi-dual-entry-send");
186
+ let parent_pid = std::env::var(PI_DUAL_ENTRY_PARENT_PID)
187
+ .expect("isolated child receives the parent test process id");
188
+ assert_ne!(
189
+ std::process::id().to_string(),
190
+ parent_pid,
191
+ "Pi send_message fixture must not run in the parent lib-test process"
192
+ );
193
+ pi_leader_and_teammate_body(&hermetic);
194
+ });
195
+ }
196
+
197
+ fn pi_leader_and_teammate_body(hermetic: &HermeticTestEnv) {
198
+ let spec = COMMAND_SPECS
199
+ .iter()
200
+ .find(|spec| spec.name == "pi")
201
+ .expect("team-agent pi must be a registered leader command");
202
+ assert_eq!(spec.kind, CommandKind::LeaderPassthrough { provider: "pi" });
203
+ assert_eq!(
204
+ crate::cli::leader::leader_passthrough_provider("pi"),
205
+ Some(Provider::Pi)
206
+ );
207
+
208
+ let leader_argv = [
209
+ "pi".to_string(),
210
+ "--".to_string(),
211
+ "--model".to_string(),
212
+ "team-agent/qwen3.8-27b".to_string(),
213
+ "--thinking".to_string(),
214
+ "max".to_string(),
215
+ ];
216
+ assert!(
217
+ crate::cli::emit::is_leader_passthrough_command(&leader_argv[0]),
218
+ "the real CLI dispatch table must route team-agent pi before generic subcommand parsing"
219
+ );
220
+ assert!(
221
+ crate::cli::emit::default_help()
222
+ .contains("team-agent codex|claude|copilot|grok|cursor|pi ..."),
223
+ "default help must advertise the dispatchable Pi launcher"
224
+ );
225
+ let leader = parse_pi_leader_args(&leader_argv[2..])
226
+ .expect("leader exact model and effort must compile into the shared plan input");
227
+ assert_eq!(leader.model, "team-agent/qwen3.8-27b");
228
+ assert_eq!(leader.effort, ProviderEffort::Max);
229
+
230
+ for invalid in [
231
+ vec!["--thinking".to_string(), "medium".to_string()],
232
+ vec!["--model".to_string(), "team-agent/qwen3.8-27b".to_string()],
233
+ vec![
234
+ "--model".to_string(),
235
+ "qwen3.8-27b".to_string(),
236
+ "--thinking".to_string(),
237
+ "medium".to_string(),
238
+ ],
239
+ vec![
240
+ "--model".to_string(),
241
+ "team-agent/qwen3.8-27b".to_string(),
242
+ "--thinking".to_string(),
243
+ "medium".to_string(),
244
+ "--mcp-config".to_string(),
245
+ "/tmp/ambient.json".to_string(),
246
+ ],
247
+ ] {
248
+ assert!(
249
+ parse_pi_leader_args(&invalid).is_err(),
250
+ "leader input must refuse missing, ambiguous, or materializer-owned fields: {invalid:?}"
251
+ );
252
+ }
253
+
254
+ let root = hermetic.workspace("core-dual");
255
+ let role = root.join("worker.md");
256
+ std::fs::write(
257
+ &role,
258
+ "---\nname: worker-a\nrole: developer\nprovider: pi\nmodel: team-agent/qwen3.8-27b\nauth_mode: subscription\neffort: max\ntools:\n - mcp_team\ndangerously_skip_permissions: true\n---\nworker contract\n",
259
+ )
260
+ .expect("write teammate role");
261
+ let teammate = compile_role_agent(&role, &Value::Map(Vec::new()), "/workspace")
262
+ .expect("provider: pi teammate must compile separately");
263
+ assert_eq!(
264
+ teammate.agent.get("provider").and_then(Value::as_str),
265
+ Some("pi")
266
+ );
267
+
268
+ std::fs::write(
269
+ &role,
270
+ "---\nname: worker-a\nrole: developer\nprovider: pi\nauth_mode: subscription\neffort: max\ntools:\n - mcp_team\ndangerously_skip_permissions: true\n---\nworker contract\n",
271
+ )
272
+ .expect("write missing-model teammate role");
273
+ assert!(
274
+ compile_role_agent(&role, &Value::Map(Vec::new()), "/workspace").is_err(),
275
+ "provider: pi teammate without an exact model must refuse"
276
+ );
277
+ std::fs::remove_dir_all(root).expect("remove role fixture");
278
+
279
+ let adapter = get_adapter(Provider::Pi);
280
+ let raw_build = adapter.build_command_plan(ProviderCommandContext {
281
+ auth_mode: AuthMode::Subscription,
282
+ mcp_config: None,
283
+ system_prompt: None,
284
+ model: None,
285
+ tools: &[],
286
+ profile_launch: None,
287
+ agent_id_hint: None,
288
+ effort: None,
289
+ });
290
+ assert!(raw_build
291
+ .expect_err("Pi adapter must refuse callers that skip the shared materializer")
292
+ .to_string()
293
+ .contains("shared lifecycle materializer"));
294
+
295
+ let dynamic_root = hermetic.workspace("teammate-dual");
296
+ let fake_role = "---\nname: mate\nrole: Dynamic Worker\nprovider: fake\nmodel: fake\nauth_mode: subscription\ndangerously_skip_permissions: false\ntools:\n - mcp_team\n---\n\ndynamic\n";
297
+
298
+ let (success_team, success_role) =
299
+ dynamic_add_fixture(&dynamic_root, "dynamic-success", fake_role);
300
+ let success_agents = crate::state::persist::load_runtime_state(&success_team)
301
+ .expect("load selected-team fixture")
302
+ .get("agents")
303
+ .cloned()
304
+ .expect("fixture agents");
305
+ let owning_socket = "/private/tmp/tmux-501/ta-owning-team";
306
+ let owning_session = "team-agent-leader-pi-owning-team";
307
+ let wrong_socket = "/private/tmp/tmux-501/ta-workspace-derived";
308
+ crate::state::persist::save_runtime_state(
309
+ &success_team,
310
+ &json!({
311
+ "active_team_key": "dynamic-success",
312
+ "tmux_endpoint": wrong_socket,
313
+ "tmux_socket": wrong_socket,
314
+ "teams": {
315
+ "dynamic-success": {
316
+ "agents": success_agents,
317
+ "team_dir": success_team,
318
+ "session_name": null,
319
+ "tmux_endpoint": wrong_socket,
320
+ "tmux_socket": wrong_socket,
321
+ "leader_receiver": {
322
+ "status": "attached",
323
+ "session_name": owning_session,
324
+ "tmux_socket": owning_socket
325
+ }
326
+ }
327
+ }
328
+ }),
329
+ )
330
+ .expect("seed nested selected-team owner");
331
+ let selected_transport =
332
+ crate::lifecycle::restart::lifecycle_worker_tmux_backend_for_selected_state(
333
+ &success_team,
334
+ Some("dynamic-success"),
335
+ )
336
+ .expect("resolve selected owning transport");
337
+ assert_eq!(
338
+ selected_transport.tmux_endpoint().as_deref(),
339
+ Some(owning_socket),
340
+ "nested selected-team receiver must beat stale top-level and team socket fields"
341
+ );
342
+ let mut annotation_state =
343
+ crate::state::persist::load_runtime_state(&success_team).expect("load annotation fixture");
344
+ let wrong_transport = OfflineTransport::new().with_tmux_endpoint(wrong_socket);
345
+ crate::lifecycle::launch::annotate_runtime_tmux_endpoint(
346
+ &mut annotation_state,
347
+ &wrong_transport,
348
+ &success_team,
349
+ );
350
+ assert_eq!(
351
+ annotation_state
352
+ .get("tmux_endpoint")
353
+ .and_then(serde_json::Value::as_str),
354
+ Some(owning_socket),
355
+ "worker-derived annotation must not clobber the owning endpoint"
356
+ );
357
+ assert_eq!(
358
+ annotation_state
359
+ .pointer("/teams/dynamic-success/leader_receiver/tmux_socket")
360
+ .and_then(serde_json::Value::as_str),
361
+ Some(owning_socket)
362
+ );
363
+ let success_transport = OfflineTransport::new()
364
+ .with_session_present(true)
365
+ .with_tmux_endpoint(owning_socket);
366
+ crate::lifecycle::add_agent_with_transport(
367
+ &success_team,
368
+ &AgentId::new("mate"),
369
+ &success_role,
370
+ false,
371
+ Some("dynamic-success"),
372
+ &success_transport,
373
+ )
374
+ .expect("fake dynamic add must spawn through the shared start path");
375
+ let spawn = success_transport.spawn_records();
376
+ assert_eq!(spawn.len(), 1, "dynamic add must spawn exactly once");
377
+ let events = crate::event_log::EventLog::new(&success_team)
378
+ .tail(50)
379
+ .expect("read dynamic add events");
380
+ let start_event = events
381
+ .iter()
382
+ .find(|event| {
383
+ event.get("event").and_then(serde_json::Value::as_str)
384
+ == Some("start_agent.agent_start")
385
+ })
386
+ .expect("start event");
387
+ let event_command = start_event
388
+ .get("command")
389
+ .and_then(serde_json::Value::as_array)
390
+ .expect("start event command")
391
+ .iter()
392
+ .map(|value| value.as_str().expect("argv string").to_string())
393
+ .collect::<Vec<_>>();
394
+ assert_eq!(
395
+ event_command, spawn[0].1,
396
+ "start event must record the exact materialized plan that was spawned"
397
+ );
398
+ assert_eq!(
399
+ start_event
400
+ .get("session")
401
+ .and_then(serde_json::Value::as_str),
402
+ Some(owning_session),
403
+ "missing team session must reuse the live owning receiver session"
404
+ );
405
+ assert_eq!(
406
+ start_event
407
+ .get("tmux_start_mode")
408
+ .and_then(serde_json::Value::as_str),
409
+ Some("new-window"),
410
+ "dynamic add must not create a second tmux session"
411
+ );
412
+
413
+ let (rollback_team, rollback_role) =
414
+ dynamic_add_fixture(&dynamic_root, "dynamic-rollback", fake_role);
415
+ let events_path = rollback_team.join(".team/logs/events.jsonl");
416
+ std::fs::create_dir_all(&events_path).expect("make the start event write fail after spawn");
417
+ let rollback_transport = OfflineTransport::new().with_session_present(true);
418
+ let rollback = crate::lifecycle::add_agent_with_transport(
419
+ &rollback_team,
420
+ &AgentId::new("mate"),
421
+ &rollback_role,
422
+ false,
423
+ None,
424
+ &rollback_transport,
425
+ );
426
+ assert!(rollback.is_err(), "post-spawn event failure must fail add");
427
+ assert!(
428
+ rollback_transport.calls().contains(&"kill_pane"),
429
+ "post-spawn failure must kill the exact spawned pane receipt"
430
+ );
431
+ let rolled_back_state =
432
+ crate::state::persist::load_runtime_state(&rollback_team).expect("load rolled back state");
433
+ assert!(
434
+ rolled_back_state.pointer("/agents/mate").is_none(),
435
+ "failed dynamic add must not retain the inserted seat"
436
+ );
437
+
438
+ let pi_role = "---\nname: mate\nrole: Pi Dynamic Worker\nprovider: pi\nmodel: team-agent/qwen3.8-27b\nauth_mode: subscription\neffort: max\ntools:\n - mcp_team\ndangerously_skip_permissions: true\n---\n\ndynamic pi\n";
439
+ let (noop_team, noop_role) = dynamic_add_fixture(&dynamic_root, "dynamic-noop", pi_role);
440
+ let noop_transport = OfflineTransport::new()
441
+ .with_session_present(true)
442
+ .with_windows(vec![WindowName::new("mate")]);
443
+ let noop = crate::lifecycle::add_agent_with_transport(
444
+ &noop_team,
445
+ &AgentId::new("mate"),
446
+ &noop_role,
447
+ false,
448
+ None,
449
+ &noop_transport,
450
+ )
451
+ .expect_err("a newly added Pi seat cannot succeed through start_agent.noop");
452
+ assert!(noop.to_string().contains("start_agent.noop"));
453
+ assert!(noop_transport.spawn_records().is_empty());
454
+ let noop_state =
455
+ crate::state::persist::load_runtime_state(&noop_team).expect("load noop rollback state");
456
+ assert!(noop_state.pointer("/agents/mate").is_none());
457
+
458
+ let (dead_team, dead_role) = dynamic_add_fixture(&dynamic_root, "dynamic-dead", fake_role);
459
+ let dead_transport = OfflineTransport::new()
460
+ .with_session_present(true)
461
+ .with_spawned_panes_addressable(false);
462
+ let dead = crate::lifecycle::add_agent_with_transport(
463
+ &dead_team,
464
+ &AgentId::new("mate"),
465
+ &dead_role,
466
+ false,
467
+ None,
468
+ &dead_transport,
469
+ )
470
+ .expect_err("dead spawned pane must not produce add-agent ok:true");
471
+ assert!(dead
472
+ .to_string()
473
+ .contains("not addressable on transport socket"));
474
+ assert!(dead_transport.calls().contains(&"kill_pane"));
475
+ let dead_state =
476
+ crate::state::persist::load_runtime_state(&dead_team).expect("load dead rollback state");
477
+ assert!(dead_state.pointer("/agents/mate").is_none());
478
+
479
+ #[cfg(unix)]
480
+ {
481
+ let live = dynamic_root.join("live-public-add");
482
+ std::fs::create_dir_all(live.join("agents")).expect("create live agents dir");
483
+ std::fs::create_dir_all(live.join("roles")).expect("create live roles dir");
484
+ std::fs::write(
485
+ live.join("TEAM.md"),
486
+ "---\nname: current\nobjective: Public no-spec add fixture.\nprovider: fake\n---\n\nfixture\n",
487
+ )
488
+ .expect("write live TEAM.md");
489
+ std::fs::write(
490
+ live.join("agents/implementer.md"),
491
+ "---\nname: implementer\nrole: Existing Worker\nprovider: fake\nmodel: fake\nauth_mode: subscription\ndangerously_skip_permissions: false\ntools:\n - mcp_team\n---\n\nexisting\n",
492
+ )
493
+ .expect("write live existing role");
494
+ std::fs::write(live.join("roles/mate.md"), fake_role).expect("write live add role");
495
+ let live_socket = "/private/tmp/tmux-501/ta-live-owning";
496
+ let live_session = "team-agent-leader-pi-live-owning";
497
+ crate::state::persist::save_runtime_state(
498
+ &live,
499
+ &json!({
500
+ "active_team_key": "current",
501
+ "tmux_endpoint": wrong_socket,
502
+ "tmux_socket": wrong_socket,
503
+ "agents": {
504
+ "implementer": {
505
+ "status": "running",
506
+ "provider": "fake",
507
+ "model": "fake",
508
+ "auth_mode": "subscription",
509
+ "window": "implementer"
510
+ }
511
+ },
512
+ "teams": {
513
+ "current": {
514
+ "status": "alive",
515
+ "team_dir": live,
516
+ "session_name": null,
517
+ "agents": {
518
+ "implementer": {
519
+ "status": "running",
520
+ "provider": "fake",
521
+ "model": "fake",
522
+ "auth_mode": "subscription",
523
+ "window": "implementer"
524
+ }
525
+ },
526
+ "leader_receiver": {
527
+ "status": "attached",
528
+ "session_name": live_session,
529
+ "tmux_socket": live_socket
530
+ }
531
+ },
532
+ "sibling": {
533
+ "status": "alive",
534
+ "team_dir": live.join("sibling"),
535
+ "session_name": null,
536
+ "agents": {},
537
+ "leader_receiver": {
538
+ "status": "attached",
539
+ "session_name": "team-agent-leader-pi-sibling",
540
+ "tmux_socket": "/private/tmp/tmux-501/ta-live-sibling"
541
+ }
542
+ }
543
+ }
544
+ }),
545
+ )
546
+ .expect("seed live nested receiver");
547
+ super::launch_spawn::seed_healthy_coordinator(&live);
548
+ let runtime_spec = crate::model::paths::runtime_spec_path(&live, "current");
549
+ assert!(
550
+ !runtime_spec.exists(),
551
+ "fixture must enter the TEAM.md fallback before runtime spec exists"
552
+ );
553
+
554
+ let shim_root = live.join("tmux-shim");
555
+ std::fs::create_dir_all(&shim_root).expect("create tmux shim dir");
556
+ let shim_log = shim_root.join("argv.log");
557
+ let shim_source = shim_root.join("tmux.c");
558
+ let shim = shim_root.join("tmux");
559
+ std::fs::write(
560
+ &shim_source,
561
+ r#"#include <stdio.h>
562
+ #include <stdlib.h>
563
+ #include <string.h>
564
+ #include <unistd.h>
565
+
566
+ static int has_arg(int argc, char **argv, const char *needle) {
567
+ for (int i = 1; i < argc; i++) {
568
+ if (strcmp(argv[i], needle) == 0) return 1;
569
+ }
570
+ return 0;
571
+ }
572
+
573
+ int main(int argc, char **argv) {
574
+ if (getenv("TEAM_AGENT_PI_TEST_HANG") != NULL) for (;;) pause();
575
+ const char *log_path = getenv("TEAM_AGENT_PI_TMUX_LOG");
576
+ const char *socket = getenv("TEAM_AGENT_PI_OWNING_SOCKET");
577
+ const char *session = getenv("TEAM_AGENT_PI_OWNING_SESSION");
578
+ const char *workspace = getenv("TEAM_AGENT_PI_RUN_WORKSPACE");
579
+ FILE *log = log_path == NULL ? NULL : fopen(log_path, "a");
580
+ if (log == NULL || socket == NULL || session == NULL || workspace == NULL) return 92;
581
+ for (int i = 1; i < argc; i++) fprintf(log, "%s%s", i == 1 ? "" : " ", argv[i]);
582
+ fputc('\n', log);
583
+ fclose(log);
584
+
585
+ int owns_socket = 0;
586
+ for (int i = 1; i + 1 < argc; i++) {
587
+ if (strcmp(argv[i], "-S") == 0 && strcmp(argv[i + 1], socket) == 0) owns_socket = 1;
588
+ }
589
+ if (!owns_socket) return 91;
590
+ if (has_arg(argc, argv, "new-window") || has_arg(argc, argv, "display-message")) {
591
+ puts("%9101");
592
+ } else if (has_arg(argc, argv, "list-panes")) {
593
+ printf("%%9101__TA_FIELD__%s__TA_FIELD__0__TA_FIELD__mate__TA_FIELD__0__TA_FIELD__/dev/ttys099__TA_FIELD__sh__TA_FIELD__1__TA_FIELD__%s__TA_FIELD__1__TA_FIELD__0__TA_FIELD__9101__TA_FIELD__\n", session, workspace);
594
+ }
595
+ return 0;
596
+ }
597
+ "#,
598
+ )
599
+ .expect("write native tmux shim source");
600
+ let compile = run_file_h_command_with_deadline(
601
+ std::process::Command::new("cc")
602
+ .args(["-O0", "-o"])
603
+ .arg(&shim)
604
+ .arg(&shim_source),
605
+ "File-H native fake compile",
606
+ FILE_H_NATIVE_COMPILE_DEADLINE,
607
+ &shim_root.join("compile.stdout"),
608
+ &shim_root.join("compile.stderr"),
609
+ )
610
+ .unwrap_or_else(|timeout| panic!("{timeout:?}"));
611
+ assert!(
612
+ compile.status.success(),
613
+ "native tmux shim compile failed: {}",
614
+ String::from_utf8_lossy(&compile.stderr)
615
+ );
616
+ let tooth_started = std::time::Instant::now();
617
+ let tooth = run_file_h_command_with_deadline(
618
+ std::process::Command::new(&shim).env("TEAM_AGENT_PI_TEST_HANG", "1"),
619
+ "File-H native fake readiness timeout tooth",
620
+ FILE_H_NATIVE_READINESS_DEADLINE,
621
+ &shim_root.join("readiness-tooth.stdout"),
622
+ &shim_root.join("readiness-tooth.stderr"),
623
+ )
624
+ .expect_err("non-exiting native fake must hit the readiness deadline");
625
+ assert_eq!(tooth.stage, "File-H native fake readiness timeout tooth");
626
+ assert_eq!(tooth.deadline, FILE_H_NATIVE_READINESS_DEADLINE);
627
+ assert!(!tooth.reaped_status.success(), "{tooth:?}");
628
+ assert!(
629
+ !crate::platform::process::pid_is_alive(tooth.pid),
630
+ "readiness timeout must leave its exact child dead and reaped: {tooth:?}"
631
+ );
632
+ assert!(
633
+ tooth_started.elapsed()
634
+ <= FILE_H_NATIVE_READINESS_DEADLINE + std::time::Duration::from_secs(1),
635
+ "readiness timeout tooth exceeded its deadline plus reap allowance: {tooth:?}"
636
+ );
637
+
638
+ let preflight = run_file_h_command_with_deadline(
639
+ std::process::Command::new(&shim)
640
+ .args(["-S", live_socket, "has-session"])
641
+ .env("TEAM_AGENT_PI_TMUX_LOG", shim_root.join("preflight.log"))
642
+ .env("TEAM_AGENT_PI_OWNING_SOCKET", live_socket)
643
+ .env("TEAM_AGENT_PI_OWNING_SESSION", live_session)
644
+ .env("TEAM_AGENT_PI_RUN_WORKSPACE", &live),
645
+ "File-H native fake has-session preflight",
646
+ FILE_H_NATIVE_READINESS_DEADLINE,
647
+ &shim_root.join("preflight.stdout"),
648
+ &shim_root.join("preflight.stderr"),
649
+ )
650
+ .unwrap_or_else(|timeout| panic!("{timeout:?}"));
651
+ assert!(
652
+ preflight.status.success(),
653
+ "native tmux shim preflight failed: {}",
654
+ String::from_utf8_lossy(&preflight.stderr)
655
+ );
656
+ let previous_path = std::env::var_os("PATH").unwrap_or_default();
657
+ let joined_path = std::env::join_paths(
658
+ std::iter::once(shim_root.clone()).chain(std::env::split_paths(&previous_path)),
659
+ )
660
+ .expect("join native shim PATH");
661
+ let _path = EnvVarGuard::set("PATH", joined_path);
662
+ let _log = EnvVarGuard::set("TEAM_AGENT_PI_TMUX_LOG", &shim_log);
663
+ let _socket = EnvVarGuard::set("TEAM_AGENT_PI_OWNING_SOCKET", live_socket);
664
+ let _session = EnvVarGuard::set("TEAM_AGENT_PI_OWNING_SESSION", live_session);
665
+ let _workspace = EnvVarGuard::set("TEAM_AGENT_PI_RUN_WORKSPACE", &live);
666
+
667
+ let tools = TeamOrchestratorTools::with_identity(
668
+ &live,
669
+ Some(AgentId::new("leader")),
670
+ Some(TeamKey::new("current")),
671
+ );
672
+ let result = tools
673
+ .add_agent("mate", "roles/mate.md")
674
+ .expect("public MCP add must use the nested owning receiver");
675
+ assert_eq!(
676
+ result.fields.get("ok").and_then(serde_json::Value::as_bool),
677
+ Some(true)
678
+ );
679
+ let tmux_argv = std::fs::read_to_string(&shim_log).expect("read tmux shim log");
680
+ assert!(
681
+ tmux_argv.contains(&format!("-S {live_socket}")),
682
+ "public add must address the selected owning socket: {tmux_argv}"
683
+ );
684
+ assert!(
685
+ tmux_argv.contains(&format!(
686
+ "new-window -d -P -F #{{pane_id}} -t {live_session} -n mate"
687
+ )),
688
+ "public add must reuse the owner session with new-window: {tmux_argv}"
689
+ );
690
+ assert!(
691
+ tmux_argv.contains(&live.to_string_lossy().to_string()),
692
+ "spawn command must retain the MCP scratch as cwd: {tmux_argv}"
693
+ );
694
+ assert!(
695
+ !tmux_argv.contains("new-session"),
696
+ "public add must not start a same-named session on another server: {tmux_argv}"
697
+ );
698
+ let parent_socket = crate::tmux_backend::socket_name_for_workspace(
699
+ live.parent().expect("live scratch parent"),
700
+ );
701
+ assert!(
702
+ !tmux_argv.contains(&parent_socket),
703
+ "public add must not derive transport from the scratch parent: {tmux_argv}"
704
+ );
705
+
706
+ let added_state = crate::state::persist::load_runtime_state(&live)
707
+ .expect("load public add state for send/status");
708
+ assert_eq!(
709
+ added_state
710
+ .pointer("/teams/current/session_name")
711
+ .and_then(serde_json::Value::as_str),
712
+ Some(live_session),
713
+ "dynamic add must persist the selected receiver session for shared send/status routing"
714
+ );
715
+ assert!(
716
+ added_state
717
+ .pointer("/teams/sibling/session_name")
718
+ .is_some_and(serde_json::Value::is_null),
719
+ "selected-team routing must not borrow or rewrite a sibling session: {added_state}"
720
+ );
721
+ assert_eq!(
722
+ added_state
723
+ .pointer("/teams/sibling/leader_receiver/session_name")
724
+ .and_then(serde_json::Value::as_str),
725
+ Some("team-agent-leader-pi-sibling")
726
+ );
727
+
728
+ let mut old_state = added_state.clone();
729
+ old_state["teams"]["current"]["session_name"] = serde_json::Value::Null;
730
+ crate::state::persist::save_runtime_state(&live, &old_state)
731
+ .expect("seed controlled old null-session view");
732
+ let blocked = tools
733
+ .send_message(
734
+ &crate::messaging::MessageTarget::Single("mate".to_string()),
735
+ "controlled old session routing probe",
736
+ None,
737
+ None,
738
+ None,
739
+ )
740
+ .expect("old public send must return a structured refusal")
741
+ .to_value();
742
+ assert_eq!(blocked.get("ok"), Some(&json!(false)), "{blocked}");
743
+ assert_eq!(
744
+ blocked.get("reason"),
745
+ Some(&json!("tmux_target_missing")),
746
+ "the old null-session view must fail at real send target resolution: {blocked}"
747
+ );
748
+ let old_status = tools
749
+ .get_team_status()
750
+ .expect("old public status must return an honest dead view");
751
+ assert_eq!(
752
+ old_status
753
+ .fields
754
+ .get("agents")
755
+ .and_then(|agents| agents.get("mate"))
756
+ .and_then(|mate| mate.get("stale_reason"))
757
+ .and_then(serde_json::Value::as_str),
758
+ Some("pane_dead"),
759
+ "the old null-session view must fail at real status liveness resolution: {:?}",
760
+ old_status.fields
761
+ );
762
+ crate::state::persist::save_runtime_state(&live, &added_state)
763
+ .expect("restore corrected selected-team session");
764
+
765
+ let sent = tools
766
+ .send_message(
767
+ &crate::messaging::MessageTarget::Single("mate".to_string()),
768
+ "session routing probe",
769
+ None,
770
+ None,
771
+ None,
772
+ )
773
+ .expect("public send must resolve the added mate")
774
+ .to_value();
775
+ assert_eq!(sent.get("status"), Some(&json!("accepted")), "{sent}");
776
+ assert!(
777
+ sent.get("message_id")
778
+ .and_then(serde_json::Value::as_str)
779
+ .is_some_and(|message_id| message_id.starts_with("msg_")),
780
+ "blocked target resolution must not be reported as a successful send: {sent}"
781
+ );
782
+
783
+ let status = tools
784
+ .get_team_status()
785
+ .expect("public status must resolve the selected owner session");
786
+ assert_eq!(
787
+ status
788
+ .fields
789
+ .get("session_name")
790
+ .and_then(serde_json::Value::as_str),
791
+ Some(live_session),
792
+ "status must report the selected receiver session"
793
+ );
794
+ assert_ne!(
795
+ status
796
+ .fields
797
+ .get("agents")
798
+ .and_then(|agents| agents.get("mate"))
799
+ .and_then(|mate| mate.get("stale_reason"))
800
+ .and_then(serde_json::Value::as_str),
801
+ Some("pane_dead"),
802
+ "the live owner-session mate must not be reported pane_dead: {:?}",
803
+ status.fields
804
+ );
805
+ }
806
+
807
+ let shared_source = include_str!("../launch/pi_mcp.rs");
808
+ let leader_source = include_str!("../../leader/start.rs");
809
+ let teammate_source = include_str!("../launch/spawn.rs");
810
+ let restart_source = include_str!("../restart/common.rs");
811
+ let restart_agent_source = include_str!("../restart/agent.rs");
812
+ let add_source = include_str!("../launch/add_agent.rs");
813
+ assert!(
814
+ leader_source.contains("materialize_pi_plan("),
815
+ "Pi leader entry must call the sole Core materializer"
816
+ );
817
+ assert!(
818
+ !leader_source.contains("Provider::Pi => provider_command_argv"),
819
+ "Pi leader must not fall back to raw passthrough argv"
820
+ );
821
+ assert_eq!(
822
+ shared_source.matches("fn materialize_pi_plan(").count(),
823
+ 1,
824
+ "leader and teammate call sites must depend on one shared materializer definition"
825
+ );
826
+ assert_eq!(
827
+ shared_source
828
+ .matches("fn materialize_pi_resume_plan(")
829
+ .count(),
830
+ 1,
831
+ "resume must share the same Core materializer module"
832
+ );
833
+ assert!(teammate_source.contains("materialize_pi_plan("));
834
+ assert!(restart_source.contains("materialize_pi_plan("));
835
+ assert!(add_source.contains("start_agent_at_paths("));
836
+ assert!(restart_agent_source.contains("&spawn.plan,"));
837
+ std::fs::remove_dir_all(dynamic_root).expect("remove dynamic fixtures");
838
+ }