@team-agent/installer 0.5.77 → 0.5.80

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 (95) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +161 -29
  4. package/crates/team-agent/src/cli/diagnose.rs +333 -68
  5. package/crates/team-agent/src/cli/emit.rs +24 -8
  6. package/crates/team-agent/src/cli/mod.rs +543 -98
  7. package/crates/team-agent/src/cli/models.rs +393 -32
  8. package/crates/team-agent/src/cli/named_address.rs +19 -5
  9. package/crates/team-agent/src/cli/profile.rs +35 -4
  10. package/crates/team-agent/src/cli/spec.rs +7 -2
  11. package/crates/team-agent/src/cli/status_port/compact.rs +12 -8
  12. package/crates/team-agent/src/cli/status_port/runtime.rs +3 -1
  13. package/crates/team-agent/src/cli/status_port/snapshot.rs +15 -12
  14. package/crates/team-agent/src/cli/status_port/store.rs +1 -1
  15. package/crates/team-agent/src/cli/status_port/tests.rs +37 -1
  16. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +62 -8
  17. package/crates/team-agent/src/cli/tests/named_address.rs +70 -0
  18. package/crates/team-agent/src/cli/tests/verb_profile.rs +81 -6
  19. package/crates/team-agent/src/cli/types.rs +1 -0
  20. package/crates/team-agent/src/coordinator/steps/abnormal.rs +122 -2
  21. package/crates/team-agent/src/leader/lease.rs +492 -33
  22. package/crates/team-agent/src/leader/owner_bind.rs +40 -12
  23. package/crates/team-agent/src/leader/rediscover/tests.rs +65 -0
  24. package/crates/team-agent/src/leader/rediscover.rs +36 -6
  25. package/crates/team-agent/src/leader/start.rs +197 -88
  26. package/crates/team-agent/src/leader/tests/wake_start_owner.rs +15 -9
  27. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +28 -227
  28. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +0 -18
  29. package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +94 -227
  30. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +59 -10
  31. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +86 -23
  32. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +1449 -282
  33. package/crates/team-agent/src/lifecycle/launch/readiness.rs +349 -4
  34. package/crates/team-agent/src/lifecycle/launch/spawn.rs +11 -11
  35. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +35 -3
  36. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +16 -0
  37. package/crates/team-agent/src/lifecycle/launch.rs +30 -7
  38. package/crates/team-agent/src/lifecycle/profile_launch.rs +37 -3
  39. package/crates/team-agent/src/lifecycle/restart/agent.rs +112 -100
  40. package/crates/team-agent/src/lifecycle/restart/common.rs +82 -52
  41. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +996 -66
  42. package/crates/team-agent/src/lifecycle/restart.rs +2 -2
  43. package/crates/team-agent/src/lifecycle/tests/acceptance_b_batch_red.rs +3 -0
  44. package/crates/team-agent/src/lifecycle/tests/behavioral_diff_264_red.rs +15 -0
  45. package/crates/team-agent/src/lifecycle/tests/claude_compatible_config_red.rs +5 -0
  46. package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +11 -13
  47. package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +119 -4
  48. package/crates/team-agent/src/lifecycle/tests/communication_mode_runtime_contract_red.rs +12 -0
  49. package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +22 -0
  50. package/crates/team-agent/src/lifecycle/tests/core_034_real_red.rs +12 -0
  51. package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +265 -198
  52. package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +14 -30
  53. package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +3 -0
  54. package/crates/team-agent/src/lifecycle/tests/fixtures/pi_list_models_spaced_ids.stdout.txt +9 -0
  55. package/crates/team-agent/src/lifecycle/tests/g1_silent_faces.rs +2 -5
  56. package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +10 -0
  57. package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +58 -35
  58. package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +10 -0
  59. package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +9 -0
  60. package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +3 -2
  61. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +0 -168
  62. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +145 -212
  63. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +16 -2
  64. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +54 -9
  65. package/crates/team-agent/src/lifecycle/tests/pi_catalog_red.rs +71 -0
  66. package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +78 -0
  67. package/crates/team-agent/src/lifecycle/tests/team_in_team_identity_scope_red.rs +3 -0
  68. package/crates/team-agent/src/lifecycle/tests/test_isolation_escape_contract.rs +51 -10
  69. package/crates/team-agent/src/lifecycle/tests/verify_rs031_window_consistency_red.rs +50 -7
  70. package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +137 -0
  71. package/crates/team-agent/src/lifecycle/types.rs +8 -0
  72. package/crates/team-agent/src/mcp_server/wire.rs +140 -11
  73. package/crates/team-agent/src/messaging/delivery.rs +221 -80
  74. package/crates/team-agent/src/messaging/leader_channel.rs +56 -0
  75. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +127 -0
  76. package/crates/team-agent/src/messaging/tests/runtime.rs +10 -2
  77. package/crates/team-agent/src/provider/adapters/cursor_agent.rs +2 -5
  78. package/crates/team-agent/src/provider/session_scan/common/tests.rs +204 -0
  79. package/crates/team-agent/src/provider/session_scan/common.rs +24 -2
  80. package/crates/team-agent/src/state/abnormal_watch/tests/mod.rs +248 -0
  81. package/crates/team-agent/src/state/abnormal_watch.rs +144 -0
  82. package/crates/team-agent/src/state/mod.rs +1 -0
  83. package/crates/team-agent/src/state/persist.rs +102 -4
  84. package/crates/team-agent/src/state/projection.rs +7 -1
  85. package/crates/team-agent/src/state/repository.rs +29 -0
  86. package/crates/team-agent/src/test_support/models_timeout_fixture.rs +43 -21
  87. package/crates/team-agent/src/tmux_backend/tests.rs +149 -4
  88. package/crates/team-agent/src/tmux_backend.rs +239 -127
  89. package/crates/team-agent/src/transport.rs +78 -0
  90. package/crates/team-agent/src/transport_factory.rs +53 -0
  91. package/package.json +4 -5
  92. package/skills/team-agent/SKILL.md +45 -17
  93. package/skills/team-agent/command-coverage.json +4 -29
  94. package/npm/check-skill-links.mjs +0 -88
  95. package/skills/team-agent/references/team-agent-operator.md +0 -672
@@ -136,8 +136,15 @@ fn run_catalog(program: &Path, timeout: Duration, max_bytes: u64) -> Result<Vec<
136
136
  #[cfg(test)]
137
137
  mod tests {
138
138
  use super::*;
139
+ #[cfg(unix)]
140
+ use std::io::Write;
141
+ #[cfg(unix)]
142
+ use std::sync::atomic::{AtomicU64, Ordering};
139
143
  use std::time::Instant;
140
144
 
145
+ #[cfg(unix)]
146
+ static FIXTURE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
147
+
141
148
  #[cfg(unix)]
142
149
  #[test]
143
150
  fn real_dispatcher_child_helper() {
@@ -241,15 +248,47 @@ mod tests {
241
248
  None
242
249
  );
243
250
  }
251
+
252
+ #[cfg(unix)]
253
+ #[test]
254
+ fn search_survives_unrelated_spaced_model_ids() {
255
+ let catalog = include_str!("../lifecycle/tests/fixtures/pi_list_models_spaced_ids.stdout.txt");
256
+ let path = fixture(&format!("printf '%s' '{catalog}'"));
257
+ let result = cmd_models_with(
258
+ &ModelsArgs {
259
+ provider: "pi".into(),
260
+ search: Some("gpt-5.6-luna".into()),
261
+ json: true,
262
+ },
263
+ &path,
264
+ Duration::from_secs(10),
265
+ MAX_CATALOG_BYTES,
266
+ )
267
+ .unwrap();
268
+ cleanup_fixture(&path);
269
+ assert_eq!(result.exit, ExitCode::Ok);
270
+ let CmdOutput::Human(text) = result.output else {
271
+ panic!("expected JSON projection")
272
+ };
273
+ let value: Value = serde_json::from_str(&text).unwrap();
274
+ assert_eq!(value["ok"], true);
275
+ assert_eq!(value["auth"], "ok");
276
+ let models = value["models"].as_array().unwrap();
277
+ assert_eq!(models.len(), 2);
278
+ assert_eq!(models[0]["role_model"], "openai-codex/gpt-5.6-luna");
279
+ assert_eq!(models[1]["role_model"], "team-agent/gpt-5.6-luna");
280
+ }
281
+
244
282
  #[cfg(unix)]
245
283
  fn fixture(body: &str) -> std::path::PathBuf {
246
284
  use std::os::unix::fs::PermissionsExt;
285
+ let sequence = FIXTURE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
247
286
  let root = std::env::current_exe()
248
287
  .unwrap()
249
288
  .parent()
250
289
  .unwrap()
251
290
  .join(format!(
252
- "team-agent-pi-fixture-{}-{}",
291
+ "team-agent-pi-fixture-{}-{}-{sequence}",
253
292
  std::process::id(),
254
293
  std::time::SystemTime::now()
255
294
  .duration_since(std::time::UNIX_EPOCH)
@@ -269,6 +308,18 @@ mod tests {
269
308
  path
270
309
  }
271
310
 
311
+ #[cfg(unix)]
312
+ fn fixture_with_sidecars(body: &str) -> std::path::PathBuf {
313
+ let path = fixture(body);
314
+ let script = std::fs::read_to_string(&path).unwrap();
315
+ let script = script.strip_prefix("#!/bin/sh\n").unwrap();
316
+ let script = format!(
317
+ "#!/bin/sh\nprintf '1\\n' > \"$0.entered\" || :\n{script}body_status=$?\nprintf '1\\n' > \"$0.completed\" || :\nexit \"$body_status\"\n"
318
+ );
319
+ std::fs::write(&path, script).unwrap();
320
+ path
321
+ }
322
+
272
323
  #[cfg(unix)]
273
324
  fn cleanup_fixture(path: &Path) {
274
325
  let _ = std::fs::remove_dir_all(path.parent().unwrap());
@@ -294,15 +345,22 @@ mod tests {
294
345
  }
295
346
 
296
347
  #[cfg(unix)]
297
- fn read_native_timeout_receipt(path: &Path) -> BTreeMap<String, String> {
348
+ fn native_timeout_stage_receipt(receipt: &Path) -> std::path::PathBuf {
349
+ receipt.with_extension("stage")
350
+ }
351
+
352
+ #[cfg(unix)]
353
+ fn parse_native_timeout_receipt(path: &Path) -> Option<BTreeMap<String, String>> {
298
354
  let mut fields = BTreeMap::new();
299
- for line in std::fs::read_to_string(path).unwrap().lines() {
300
- let (key, value) = line.split_once('=').expect("receipt key/value");
301
- assert!(fields.insert(key.to_string(), value.to_string()).is_none());
355
+ for line in std::fs::read_to_string(path).ok()?.lines() {
356
+ let (key, value) = line.split_once('=')?;
357
+ if fields.insert(key.to_string(), value.to_string()).is_some() {
358
+ return None;
359
+ }
302
360
  }
303
- assert_eq!(
304
- fields.keys().map(String::as_str).collect::<Vec<_>>(),
305
- vec![
361
+ let keys = fields.keys().map(String::as_str).collect::<Vec<_>>();
362
+ (keys
363
+ == vec![
306
364
  "child_argv_exact",
307
365
  "child_pid",
308
366
  "child_spawn_attempts",
@@ -312,9 +370,22 @@ mod tests {
312
370
  "parent_argv_exact",
313
371
  "parent_pid",
314
372
  "parent_started_once",
315
- ]
316
- );
317
- fields
373
+ ])
374
+ .then_some(fields)
375
+ }
376
+
377
+ #[cfg(unix)]
378
+ fn wait_native_timeout_receipt(path: &Path) -> BTreeMap<String, String> {
379
+ let deadline = Instant::now() + Duration::from_millis(500);
380
+ loop {
381
+ if let Some(fields) = parse_native_timeout_receipt(path) {
382
+ return fields;
383
+ }
384
+ if Instant::now() >= deadline {
385
+ panic!("native timeout receipt did not become complete after runner return");
386
+ }
387
+ std::thread::sleep(Duration::from_millis(1));
388
+ }
318
389
  }
319
390
 
320
391
  #[cfg(unix)]
@@ -334,15 +405,112 @@ mod tests {
334
405
  result
335
406
  }
336
407
 
408
+ #[cfg(unix)]
409
+ fn safe_fixture_sidecar(path: &Path, expected: &str) -> &'static str {
410
+ match std::fs::read_to_string(path) {
411
+ Ok(text) if text == expected => "present_expected",
412
+ Ok(_) => "present_unexpected",
413
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => "missing",
414
+ Err(_) => "unavailable",
415
+ }
416
+ }
417
+
418
+ #[cfg(unix)]
419
+ fn safe_runner_fixture_observation(path: &Path) -> String {
420
+ format!(
421
+ "fixture_entered={} fixture_completed={} fixture_count={}",
422
+ safe_fixture_sidecar(&path.with_extension("entered"), "1\n"),
423
+ safe_fixture_sidecar(&path.with_extension("completed"), "1\n"),
424
+ safe_fixture_sidecar(&path.with_extension("count"), "1:--list-models\n"),
425
+ )
426
+ }
427
+
428
+ #[cfg(unix)]
429
+ fn safe_runner_observation(
430
+ elapsed: Duration,
431
+ observation: &crate::lifecycle::launch::pi_mcp::PiCatalogTestObservation,
432
+ ) -> String {
433
+ format!(
434
+ "elapsed_ms={} spawn_count={} argv={:?} parent_pid={:?} parent_exit_success={:?} parent_exit_code={:?} reader_timeout={}",
435
+ elapsed.as_millis(),
436
+ observation.spawn_count,
437
+ observation.argv,
438
+ observation.parent_pid,
439
+ observation.parent_exit_success,
440
+ observation.parent_exit_code,
441
+ observation.reader_timeout,
442
+ )
443
+ }
444
+
445
+ #[cfg(unix)]
446
+ fn safe_models_result_class(result: &CmdResult) -> &'static str {
447
+ let CmdOutput::Human(text) = &result.output else {
448
+ return "non_human_output";
449
+ };
450
+ let Ok(value) = serde_json::from_str::<Value>(text) else {
451
+ return "non_json_output";
452
+ };
453
+ match value.get("ok").and_then(Value::as_bool) {
454
+ Some(true) => "success_json",
455
+ Some(false) => match value.get("error").and_then(Value::as_str) {
456
+ Some(error) if error.contains("unavailable") => "runner_unavailable",
457
+ Some(error) if error.contains("timed out") => "runner_timeout",
458
+ Some(error) if error.contains("command failed") => "runner_exit",
459
+ Some(error) if error.contains("could not be read") => "reader_error",
460
+ Some(error) if error.contains("exceeds") => "reader_oversize",
461
+ Some(error) if error.contains("catalog") => "parser_catalog_error",
462
+ Some(_) => "failure_json",
463
+ None => "failure_json_missing_error",
464
+ },
465
+ None => "json_missing_ok",
466
+ }
467
+ }
468
+
469
+ #[cfg(unix)]
470
+ fn safe_models_observation(
471
+ elapsed: Duration,
472
+ result: &CmdResult,
473
+ observation: &crate::lifecycle::launch::pi_mcp::PiCatalogTestObservation,
474
+ ) -> String {
475
+ format!(
476
+ "class={} elapsed_ms={} parent_pid={:?} parent_exit_success={:?} parent_exit_code={:?} reader_timeout={}",
477
+ safe_models_result_class(result),
478
+ elapsed.as_millis(),
479
+ observation.parent_pid,
480
+ observation.parent_exit_success,
481
+ observation.parent_exit_code,
482
+ observation.reader_timeout,
483
+ )
484
+ }
485
+
337
486
  #[cfg(unix)]
338
487
  #[test]
339
488
  fn runner_invokes_exact_argv_once_and_drains() {
340
- let path = fixture(
489
+ let path = fixture_with_sidecars(
341
490
  "test \"$1\" = --list-models && echo provider model && echo openai-codex gpt-5.6-sol",
342
491
  );
343
- assert!(run_catalog(&path, Duration::from_secs(1), 1024)
344
- .unwrap()
345
- .starts_with(b"provider model"));
492
+ let receipt = native_timeout_receipt();
493
+ assert!(!receipt.exists());
494
+ let started = Instant::now();
495
+ let (result, observation) =
496
+ crate::lifecycle::launch::pi_mcp::with_pi_catalog_test_observation(
497
+ &receipt,
498
+ None,
499
+ || run_catalog(&path, Duration::from_secs(1), 1024),
500
+ );
501
+ let elapsed = started.elapsed();
502
+ let fixture_observation = safe_runner_fixture_observation(&path);
503
+ assert!(
504
+ result
505
+ .as_ref()
506
+ .is_ok_and(|bytes| bytes.starts_with(b"provider model")),
507
+ "runner observation: {}; {}",
508
+ safe_runner_observation(elapsed, &observation),
509
+ fixture_observation
510
+ );
511
+ let _ = result.unwrap();
512
+ let _ = std::fs::remove_file(&receipt);
513
+ let _ = std::fs::remove_file(format!("{}.sock", receipt.display()));
346
514
  assert_eq!(
347
515
  std::fs::read_to_string(path.with_extension("count")).unwrap(),
348
516
  "1:--list-models\n"
@@ -350,28 +518,160 @@ mod tests {
350
518
  cleanup_fixture(&path);
351
519
  }
352
520
 
521
+ #[cfg(unix)]
522
+ const NATIVE_STAGE_KEYS: [&str; 5] = [
523
+ "parent_entry_ms",
524
+ "parent_before_child_spawn_ms",
525
+ "parent_after_child_spawn_ms",
526
+ "parent_before_exit_ms",
527
+ "test_spawn_return_ms",
528
+ ];
529
+
530
+ #[cfg(unix)]
531
+ // Match the native helper's UNIX epoch millisecond clock exactly.
532
+ fn append_native_stage(path: &Path, key: &str) {
533
+ if !NATIVE_STAGE_KEYS.contains(&key) {
534
+ return;
535
+ }
536
+ let Some(milliseconds) = std::time::SystemTime::now()
537
+ .duration_since(std::time::UNIX_EPOCH)
538
+ .ok()
539
+ .map(|duration| duration.as_millis())
540
+ else {
541
+ return;
542
+ };
543
+ let Ok(mut file) = std::fs::OpenOptions::new()
544
+ .create(true)
545
+ .append(true)
546
+ .open(path)
547
+ else {
548
+ return;
549
+ };
550
+ let _ = writeln!(file, "{key}={milliseconds}");
551
+ }
552
+
553
+ #[cfg(unix)]
554
+ fn safe_native_stage(path: &Path) -> String {
555
+ let (read_state, text) = match std::fs::read_to_string(path) {
556
+ Ok(text) => ("ok", Some(text)),
557
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => ("missing", None),
558
+ Err(_) => ("unavailable", None),
559
+ };
560
+ let mut fields = BTreeMap::new();
561
+ if let Some(ref text) = text {
562
+ for line in text.lines() {
563
+ let Some((key, value)) = line.split_once('=') else {
564
+ continue;
565
+ };
566
+ if NATIVE_STAGE_KEYS.contains(&key)
567
+ && value.len() <= 20
568
+ && value.bytes().all(|byte| byte.is_ascii_digit())
569
+ {
570
+ fields.entry(key).or_insert(value);
571
+ }
572
+ }
573
+ }
574
+ let field = |key: &str| fields.get(key).copied().unwrap_or("unknown");
575
+ format!(
576
+ "stage_read={read_state} parent_entry_ms={} parent_before_child_spawn_ms={} parent_after_child_spawn_ms={} parent_before_exit_ms={} test_spawn_return_ms={}",
577
+ field("parent_entry_ms"),
578
+ field("parent_before_child_spawn_ms"),
579
+ field("parent_after_child_spawn_ms"),
580
+ field("parent_before_exit_ms"),
581
+ field("test_spawn_return_ms"),
582
+ )
583
+ }
584
+
585
+ #[cfg(unix)]
586
+ fn wait_for_native_parent_exit(
587
+ child: &mut std::process::Child,
588
+ stage: &Path,
589
+ ) -> std::process::ExitStatus {
590
+ let deadline = Instant::now() + Duration::from_millis(500);
591
+ loop {
592
+ match child.try_wait() {
593
+ Ok(Some(status)) => return status,
594
+ Ok(None) if Instant::now() < deadline => {
595
+ std::thread::sleep(Duration::from_millis(1));
596
+ }
597
+ Ok(None) => {
598
+ let before_kill = safe_native_stage(stage);
599
+ let _ = child.kill();
600
+ let _ = child.wait();
601
+ let after_wait = safe_native_stage(stage);
602
+ panic!(
603
+ "native parent did not exit before test setup deadline; stage_before_kill={before_kill}; stage_after_wait={after_wait}"
604
+ );
605
+ }
606
+ Err(_) => {
607
+ let before_kill = safe_native_stage(stage);
608
+ let _ = child.kill();
609
+ let _ = child.wait();
610
+ let after_wait = safe_native_stage(stage);
611
+ panic!(
612
+ "native parent status could not be observed; stage_before_kill={before_kill}; stage_after_wait={after_wait}"
613
+ );
614
+ }
615
+ }
616
+ }
617
+ }
618
+
619
+ /// Read/drain seam only: public `run_catalog` spawn coverage remains in the
620
+ /// normal success and fail-closed tests.
353
621
  #[cfg(unix)]
354
622
  #[test]
355
- fn runner_timeout_is_bounded_when_descendant_keeps_stdout() {
623
+ fn reader_deadline_is_bounded_after_parent_exit_with_descendant_pipe() {
356
624
  let path = native_timeout_fixture();
357
625
  let receipt = native_timeout_receipt();
626
+ let stage = native_timeout_stage_receipt(&receipt);
358
627
  assert!(!receipt.exists());
628
+ assert!(!stage.exists());
629
+ let mut parent = std::process::Command::new(&path)
630
+ .arg("--list-models")
631
+ .env("TEAM_AGENT_MODELS_TIMEOUT_RECEIPT", &receipt)
632
+ .env("TEAM_AGENT_MODELS_TIMEOUT_STAGE_RECEIPT", &stage)
633
+ .env("TEAM_AGENT_MODELS_TIMEOUT_MODE", "descendant")
634
+ .stdin(std::process::Stdio::null())
635
+ .stdout(std::process::Stdio::piped())
636
+ .stderr(std::process::Stdio::null())
637
+ .spawn()
638
+ .unwrap();
639
+ append_native_stage(&stage, "test_spawn_return_ms");
640
+ let parent_pid = parent.id();
641
+ let parent_status = wait_for_native_parent_exit(&mut parent, &stage);
642
+ let stdout = parent.stdout.take().expect("native parent stdout pipe");
359
643
  let started = Instant::now();
360
644
  let (result, observation) =
361
645
  crate::lifecycle::launch::pi_mcp::with_pi_catalog_test_observation(
362
646
  &receipt,
363
647
  Some("descendant"),
364
- || run_catalog(&path, Duration::from_millis(40), 1024),
648
+ || {
649
+ crate::lifecycle::launch::pi_mcp::run_pi_catalog_after_parent_exit_for_test(
650
+ stdout,
651
+ parent_pid,
652
+ parent_status,
653
+ Duration::from_millis(40),
654
+ 1024,
655
+ )
656
+ },
365
657
  );
366
- assert!(started.elapsed() < Duration::from_millis(500));
658
+ let elapsed = started.elapsed();
659
+ assert!(
660
+ elapsed < Duration::from_millis(500),
661
+ "runner observation: {}",
662
+ safe_runner_observation(elapsed, &observation)
663
+ );
367
664
  assert!(result.unwrap_err().contains("timed out"));
368
- assert_eq!(observation.spawn_count, 1);
369
- assert_eq!(observation.argv, vec!["--list-models"]);
370
- assert_eq!(observation.parent_exit_success, Some(true));
665
+ assert_eq!(
666
+ observation.parent_exit_success,
667
+ Some(true),
668
+ "runner observation: {}",
669
+ safe_runner_observation(elapsed, &observation)
670
+ );
371
671
  assert_eq!(observation.parent_exit_code, Some(0));
372
672
  assert!(observation.reader_timeout);
373
673
 
374
- let fields = read_native_timeout_receipt(&receipt);
674
+ let fields = wait_native_timeout_receipt(&receipt);
375
675
  assert_eq!(fields["parent_started_once"], "1");
376
676
  assert_eq!(fields["parent_argv_exact"], "1");
377
677
  assert_eq!(fields["child_spawn_attempts"], "1");
@@ -384,6 +684,7 @@ mod tests {
384
684
  );
385
685
  assert_eq!(fields["child_pid"], fields["descendant_pid"]);
386
686
  let _ = std::fs::remove_file(&receipt);
687
+ let _ = std::fs::remove_file(&stage);
387
688
  let _ = std::fs::remove_file(format!("{}.sock", receipt.display()));
388
689
  }
389
690
 
@@ -398,16 +699,72 @@ mod tests {
398
699
  search: Some("sol".into()),
399
700
  json: true,
400
701
  };
401
- let result = cmd_models_with(&args, &path, Duration::from_secs(1), 1024).unwrap();
402
- let CmdOutput::Human(text) = result.output else {
403
- panic!("expected JSON projection")
702
+ let receipt = native_timeout_receipt();
703
+ assert!(!receipt.exists());
704
+ let started = Instant::now();
705
+ let (result, observation) =
706
+ crate::lifecycle::launch::pi_mcp::with_pi_catalog_test_observation(
707
+ &receipt,
708
+ None,
709
+ || cmd_models_with(&args, &path, Duration::from_secs(1), 1024),
710
+ );
711
+ let elapsed = started.elapsed();
712
+ let result = match result {
713
+ Ok(result) => result,
714
+ Err(_) => panic!(
715
+ "command boundary returned a CLI error; class=cli_error elapsed_ms={} parent_pid={:?} parent_exit_success={:?} parent_exit_code={:?} reader_timeout={}",
716
+ elapsed.as_millis(),
717
+ observation.parent_pid,
718
+ observation.parent_exit_success,
719
+ observation.parent_exit_code,
720
+ observation.reader_timeout,
721
+ ),
404
722
  };
405
- let value: Value = serde_json::from_str(&text).unwrap();
406
- assert_eq!(value["schema_version"], "models.v1");
407
- assert_eq!(value["models"][0]["role_model"], "openai-codex/gpt-5.6-sol");
408
- assert_eq!(value["models"][0]["current"], false);
409
- assert_eq!(value["auth"], "ok");
410
- assert_eq!(value["models"].as_array().unwrap().len(), 1);
723
+ let CmdOutput::Human(text) = &result.output else {
724
+ panic!(
725
+ "expected JSON projection; {}",
726
+ safe_models_observation(elapsed, &result, &observation)
727
+ )
728
+ };
729
+ let value: Value = serde_json::from_str(text).unwrap_or_else(|_| {
730
+ panic!(
731
+ "expected valid JSON projection; {}",
732
+ safe_models_observation(elapsed, &result, &observation)
733
+ )
734
+ });
735
+ assert_eq!(
736
+ value["schema_version"],
737
+ "models.v1",
738
+ "{}",
739
+ safe_models_observation(elapsed, &result, &observation)
740
+ );
741
+ assert_eq!(
742
+ value["models"][0]["role_model"],
743
+ "openai-codex/gpt-5.6-sol",
744
+ "{}",
745
+ safe_models_observation(elapsed, &result, &observation)
746
+ );
747
+ assert_eq!(
748
+ value["models"][0]["current"],
749
+ false,
750
+ "{}",
751
+ safe_models_observation(elapsed, &result, &observation)
752
+ );
753
+ assert_eq!(
754
+ value["auth"],
755
+ "ok",
756
+ "{}",
757
+ safe_models_observation(elapsed, &result, &observation)
758
+ );
759
+ let model_count = value["models"].as_array().map(|models| models.len());
760
+ assert_eq!(
761
+ model_count,
762
+ Some(1),
763
+ "{}",
764
+ safe_models_observation(elapsed, &result, &observation)
765
+ );
766
+ let _ = std::fs::remove_file(&receipt);
767
+ let _ = std::fs::remove_file(format!("{}.sock", receipt.display()));
411
768
  let human = cmd_models_with(
412
769
  &ModelsArgs {
413
770
  provider: "pi".into(),
@@ -441,12 +798,16 @@ mod tests {
441
798
  #[test]
442
799
  fn runner_fails_closed_for_nonzero_oversize_timeout_and_unavailable() {
443
800
  let fail = native_timeout_fixture();
801
+ let started = Instant::now();
444
802
  let (failed_result, fail_observation) = with_native_mode("exit7", || {
445
803
  run_catalog(&fail, Duration::from_secs(1), 1024)
446
804
  });
805
+ let elapsed = started.elapsed();
447
806
  assert_eq!(
448
807
  failed_result.unwrap_err(),
449
- "Pi model catalog command failed"
808
+ "Pi model catalog command failed",
809
+ "runner observation: {}",
810
+ safe_runner_observation(elapsed, &fail_observation)
450
811
  );
451
812
  assert_eq!(fail_observation.spawn_count, 1);
452
813
  assert_eq!(fail_observation.argv, vec!["--list-models"]);
@@ -694,9 +694,23 @@ fn resolve_worker(
694
694
  }
695
695
  };
696
696
  let matches = matching_session_window(&targets, session, window);
697
- match matches.len() {
698
- 1 => {
699
- let pane = matches[0];
697
+ let selected_pane = match matches.len() {
698
+ 1 => matches.first().copied(),
699
+ n if n > 1 => {
700
+ let state_pane_id =
701
+ string_field(agent_entry, "pane_id").filter(|pane_id| !pane_id.is_empty());
702
+ let mut state_matches = matches.iter().copied().filter(|pane| {
703
+ state_pane_id.is_some_and(|pane_id| pane.pane_id.as_str() == pane_id)
704
+ });
705
+ match (state_matches.next(), state_matches.next()) {
706
+ (Some(pane), None) => Some(pane),
707
+ _ => None,
708
+ }
709
+ }
710
+ _ => None,
711
+ };
712
+ match selected_pane {
713
+ Some(pane) => {
700
714
  let state_pane_id = string_field(agent_entry, "pane_id").map(str::to_string);
701
715
  let state_pane_stale = state_pane_id
702
716
  .as_deref()
@@ -727,7 +741,7 @@ fn resolve_worker(
727
741
  warning,
728
742
  })
729
743
  }
730
- 0 => Ok(resolved_worker_without_live(
744
+ None if matches.is_empty() => Ok(resolved_worker_without_live(
731
745
  sender_workspace,
732
746
  target_workspace,
733
747
  team,
@@ -738,7 +752,7 @@ fn resolve_worker(
738
752
  window,
739
753
  transport.tmux_endpoint(),
740
754
  )),
741
- _ => Err(name_ambiguous(
755
+ None => Err(name_ambiguous(
742
756
  "multiple live panes match the same session/window",
743
757
  matches
744
758
  .into_iter()
@@ -20,17 +20,20 @@ pub fn cmd_profile(args: &ProfileArgs) -> Result<CmdResult, CliError> {
20
20
  fn init_profile(args: &ProfileArgs) -> Result<Value, CliError> {
21
21
  let auth_mode = args.auth_mode.as_deref().unwrap_or("subscription");
22
22
  validate_auth_mode(auth_mode)?;
23
+ let proxy_mode = args.proxy_mode.as_deref().unwrap_or("inherit");
24
+ validate_proxy_mode(proxy_mode)?;
23
25
  let dir = profile_dir(args);
24
26
  std::fs::create_dir_all(&dir)?;
25
27
  let path = dir.join(format!("{}.env", args.name));
26
28
  let template_path = dir.join(format!("{}.example.env", args.name));
27
- let body = profile_template(&args.name, auth_mode);
29
+ let body = profile_template(&args.name, auth_mode, proxy_mode);
28
30
  let created_profile = write_new_file(&path, &body)?;
29
31
  if created_profile {
30
32
  set_owner_only_permissions(&path)?;
31
33
  }
32
34
  let created_template = write_new_file(&template_path, &body)?;
33
35
  write_boundary_files(&dir)?;
36
+ let actual_proxy_mode = display_proxy_mode(&read_profile_env(&path)?);
34
37
 
35
38
  let mut obj = Map::new();
36
39
  obj.insert("ok".to_string(), Value::Bool(true));
@@ -39,6 +42,14 @@ fn init_profile(args: &ProfileArgs) -> Result<Value, CliError> {
39
42
  "auth_mode".to_string(),
40
43
  Value::String(auth_mode.to_string()),
41
44
  );
45
+ obj.insert(
46
+ "proxy_mode".to_string(),
47
+ Value::String(actual_proxy_mode),
48
+ );
49
+ obj.insert(
50
+ "proxy_mode_changed".to_string(),
51
+ Value::Bool(created_profile),
52
+ );
42
53
  obj.insert("path".to_string(), path_value(&path));
43
54
  obj.insert("template_path".to_string(), path_value(&template_path));
44
55
  obj.insert("created_profile".to_string(), Value::Bool(created_profile));
@@ -72,6 +83,7 @@ fn doctor_profile(args: &ProfileArgs) -> Result<Value, CliError> {
72
83
  let values = read_profile_env(&path)?;
73
84
  let auth_mode =
74
85
  profile_value(&values, "AUTH_MODE").unwrap_or_else(|| "subscription".to_string());
86
+ let proxy_mode = display_proxy_mode(&values);
75
87
  let secret_keys = secret_keys_present(&values);
76
88
  let keys = sorted_keys(&values);
77
89
  let mut obj = Map::new();
@@ -83,6 +95,7 @@ fn doctor_profile(args: &ProfileArgs) -> Result<Value, CliError> {
83
95
  Value::Bool(credential_present(&values)),
84
96
  );
85
97
  obj.insert("auth_mode".to_string(), Value::String(auth_mode));
98
+ obj.insert("proxy_mode".to_string(), Value::String(proxy_mode));
86
99
  obj.insert("keys_present".to_string(), string_array(keys));
87
100
  obj.insert(
88
101
  "raw_file_read_allowed_for_agents".to_string(),
@@ -112,6 +125,7 @@ fn show_profile(args: &ProfileArgs) -> Result<Value, CliError> {
112
125
  let values = read_profile_env(&path)?;
113
126
  let auth_mode =
114
127
  profile_value(&values, "AUTH_MODE").unwrap_or_else(|| "subscription".to_string());
128
+ let proxy_mode = display_proxy_mode(&values);
115
129
  let secret_keys = secret_keys_present(&values);
116
130
  let missing_common = missing_common_keys(&values, &auth_mode);
117
131
  let mut obj = Map::new();
@@ -122,6 +136,7 @@ fn show_profile(args: &ProfileArgs) -> Result<Value, CliError> {
122
136
  Value::Bool(credential_present(&values)),
123
137
  );
124
138
  obj.insert("auth_mode".to_string(), Value::String(auth_mode));
139
+ obj.insert("proxy_mode".to_string(), Value::String(proxy_mode));
125
140
  obj.insert("values".to_string(), redacted_values(&values));
126
141
  obj.insert(
127
142
  "keys_present".to_string(),
@@ -157,12 +172,21 @@ fn validate_auth_mode(auth_mode: &str) -> Result<(), CliError> {
157
172
  }
158
173
  }
159
174
 
160
- fn profile_template(name: &str, auth_mode: &str) -> String {
175
+ fn validate_proxy_mode(proxy_mode: &str) -> Result<(), CliError> {
176
+ match proxy_mode {
177
+ "direct" | "inherit" => Ok(()),
178
+ other => Err(CliError::Usage(format!(
179
+ "invalid --proxy-mode: {other} (expected direct|inherit)"
180
+ ))),
181
+ }
182
+ }
183
+
184
+ fn profile_template(name: &str, auth_mode: &str, proxy_mode: &str) -> String {
161
185
  match auth_mode {
162
186
  "compatible_api" | "official_api" => {
163
- format!("AUTH_MODE={auth_mode}\nPROFILE_NAME={name}\nBASE_URL=\nAPI_KEY=\nMODEL=\n")
187
+ format!("AUTH_MODE={auth_mode}\nPROFILE_NAME={name}\nPROXY_MODE={proxy_mode}\nBASE_URL=\nAPI_KEY=\nMODEL=\n")
164
188
  }
165
- _ => format!("AUTH_MODE=subscription\nPROFILE_NAME={name}\n"),
189
+ _ => format!("AUTH_MODE=subscription\nPROFILE_NAME={name}\nPROXY_MODE={proxy_mode}\n"),
166
190
  }
167
191
  }
168
192
 
@@ -235,6 +259,13 @@ fn profile_value(values: &Map<String, Value>, key: &str) -> Option<String> {
235
259
  .map(ToString::to_string)
236
260
  }
237
261
 
262
+ fn display_proxy_mode(values: &Map<String, Value>) -> String {
263
+ profile_value(values, "PROXY_MODE")
264
+ .or_else(|| profile_value(values, "NETWORK_MODE"))
265
+ .map(|value| value.trim().to_ascii_lowercase())
266
+ .unwrap_or_else(|| "inherit".to_string())
267
+ }
268
+
238
269
  fn missing_profile_value(args: &ProfileArgs, path: &Path, template_path: Option<&Path>) -> Value {
239
270
  let mut obj = Map::new();
240
271
  obj.insert("ok".to_string(), Value::Bool(false));