@team-agent/installer 0.5.77 → 0.5.78

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 (83) 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 +362 -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/snapshot.rs +15 -12
  13. package/crates/team-agent/src/cli/status_port/store.rs +1 -1
  14. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +62 -8
  15. package/crates/team-agent/src/cli/tests/named_address.rs +70 -0
  16. package/crates/team-agent/src/cli/tests/verb_profile.rs +81 -6
  17. package/crates/team-agent/src/cli/types.rs +1 -0
  18. package/crates/team-agent/src/leader/lease.rs +492 -33
  19. package/crates/team-agent/src/leader/owner_bind.rs +40 -12
  20. package/crates/team-agent/src/leader/rediscover/tests.rs +65 -0
  21. package/crates/team-agent/src/leader/rediscover.rs +36 -6
  22. package/crates/team-agent/src/leader/start.rs +151 -14
  23. package/crates/team-agent/src/leader/tests/wake_start_owner.rs +15 -9
  24. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +28 -227
  25. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +0 -18
  26. package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +94 -227
  27. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +59 -10
  28. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +65 -18
  29. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +1449 -282
  30. package/crates/team-agent/src/lifecycle/launch/readiness.rs +349 -4
  31. package/crates/team-agent/src/lifecycle/launch/spawn.rs +11 -11
  32. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +35 -3
  33. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +16 -0
  34. package/crates/team-agent/src/lifecycle/launch.rs +30 -7
  35. package/crates/team-agent/src/lifecycle/profile_launch.rs +37 -3
  36. package/crates/team-agent/src/lifecycle/restart/agent.rs +112 -100
  37. package/crates/team-agent/src/lifecycle/restart/common.rs +82 -52
  38. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +996 -66
  39. package/crates/team-agent/src/lifecycle/restart.rs +2 -2
  40. package/crates/team-agent/src/lifecycle/tests/acceptance_b_batch_red.rs +3 -0
  41. package/crates/team-agent/src/lifecycle/tests/behavioral_diff_264_red.rs +15 -0
  42. package/crates/team-agent/src/lifecycle/tests/claude_compatible_config_red.rs +5 -0
  43. package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +11 -13
  44. package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +119 -4
  45. package/crates/team-agent/src/lifecycle/tests/communication_mode_runtime_contract_red.rs +12 -0
  46. package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +22 -0
  47. package/crates/team-agent/src/lifecycle/tests/core_034_real_red.rs +12 -0
  48. package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +265 -198
  49. package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +14 -30
  50. package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +3 -0
  51. package/crates/team-agent/src/lifecycle/tests/g1_silent_faces.rs +2 -5
  52. package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +10 -0
  53. package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +58 -35
  54. package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +10 -0
  55. package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +9 -0
  56. package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +3 -2
  57. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +0 -168
  58. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +145 -212
  59. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +16 -2
  60. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +54 -9
  61. package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +78 -0
  62. package/crates/team-agent/src/lifecycle/tests/team_in_team_identity_scope_red.rs +3 -0
  63. package/crates/team-agent/src/lifecycle/tests/test_isolation_escape_contract.rs +51 -10
  64. package/crates/team-agent/src/lifecycle/tests/verify_rs031_window_consistency_red.rs +50 -7
  65. package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +137 -0
  66. package/crates/team-agent/src/lifecycle/types.rs +8 -0
  67. package/crates/team-agent/src/mcp_server/wire.rs +140 -11
  68. package/crates/team-agent/src/messaging/delivery.rs +67 -8
  69. package/crates/team-agent/src/messaging/leader_channel.rs +56 -0
  70. package/crates/team-agent/src/messaging/tests/runtime.rs +10 -2
  71. package/crates/team-agent/src/provider/adapters/cursor_agent.rs +2 -5
  72. package/crates/team-agent/src/state/persist.rs +97 -1
  73. package/crates/team-agent/src/state/repository.rs +29 -0
  74. package/crates/team-agent/src/test_support/models_timeout_fixture.rs +43 -21
  75. package/crates/team-agent/src/tmux_backend/tests.rs +121 -4
  76. package/crates/team-agent/src/tmux_backend.rs +208 -118
  77. package/crates/team-agent/src/transport.rs +72 -0
  78. package/crates/team-agent/src/transport_factory.rs +53 -0
  79. package/package.json +4 -5
  80. package/skills/team-agent/SKILL.md +45 -17
  81. package/skills/team-agent/command-coverage.json +4 -29
  82. package/npm/check-skill-links.mjs +0 -88
  83. 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() {
@@ -244,12 +251,13 @@ mod tests {
244
251
  #[cfg(unix)]
245
252
  fn fixture(body: &str) -> std::path::PathBuf {
246
253
  use std::os::unix::fs::PermissionsExt;
254
+ let sequence = FIXTURE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
247
255
  let root = std::env::current_exe()
248
256
  .unwrap()
249
257
  .parent()
250
258
  .unwrap()
251
259
  .join(format!(
252
- "team-agent-pi-fixture-{}-{}",
260
+ "team-agent-pi-fixture-{}-{}-{sequence}",
253
261
  std::process::id(),
254
262
  std::time::SystemTime::now()
255
263
  .duration_since(std::time::UNIX_EPOCH)
@@ -269,6 +277,18 @@ mod tests {
269
277
  path
270
278
  }
271
279
 
280
+ #[cfg(unix)]
281
+ fn fixture_with_sidecars(body: &str) -> std::path::PathBuf {
282
+ let path = fixture(body);
283
+ let script = std::fs::read_to_string(&path).unwrap();
284
+ let script = script.strip_prefix("#!/bin/sh\n").unwrap();
285
+ let script = format!(
286
+ "#!/bin/sh\nprintf '1\\n' > \"$0.entered\" || :\n{script}body_status=$?\nprintf '1\\n' > \"$0.completed\" || :\nexit \"$body_status\"\n"
287
+ );
288
+ std::fs::write(&path, script).unwrap();
289
+ path
290
+ }
291
+
272
292
  #[cfg(unix)]
273
293
  fn cleanup_fixture(path: &Path) {
274
294
  let _ = std::fs::remove_dir_all(path.parent().unwrap());
@@ -294,15 +314,22 @@ mod tests {
294
314
  }
295
315
 
296
316
  #[cfg(unix)]
297
- fn read_native_timeout_receipt(path: &Path) -> BTreeMap<String, String> {
317
+ fn native_timeout_stage_receipt(receipt: &Path) -> std::path::PathBuf {
318
+ receipt.with_extension("stage")
319
+ }
320
+
321
+ #[cfg(unix)]
322
+ fn parse_native_timeout_receipt(path: &Path) -> Option<BTreeMap<String, String>> {
298
323
  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());
324
+ for line in std::fs::read_to_string(path).ok()?.lines() {
325
+ let (key, value) = line.split_once('=')?;
326
+ if fields.insert(key.to_string(), value.to_string()).is_some() {
327
+ return None;
328
+ }
302
329
  }
303
- assert_eq!(
304
- fields.keys().map(String::as_str).collect::<Vec<_>>(),
305
- vec![
330
+ let keys = fields.keys().map(String::as_str).collect::<Vec<_>>();
331
+ (keys
332
+ == vec![
306
333
  "child_argv_exact",
307
334
  "child_pid",
308
335
  "child_spawn_attempts",
@@ -312,9 +339,22 @@ mod tests {
312
339
  "parent_argv_exact",
313
340
  "parent_pid",
314
341
  "parent_started_once",
315
- ]
316
- );
317
- fields
342
+ ])
343
+ .then_some(fields)
344
+ }
345
+
346
+ #[cfg(unix)]
347
+ fn wait_native_timeout_receipt(path: &Path) -> BTreeMap<String, String> {
348
+ let deadline = Instant::now() + Duration::from_millis(500);
349
+ loop {
350
+ if let Some(fields) = parse_native_timeout_receipt(path) {
351
+ return fields;
352
+ }
353
+ if Instant::now() >= deadline {
354
+ panic!("native timeout receipt did not become complete after runner return");
355
+ }
356
+ std::thread::sleep(Duration::from_millis(1));
357
+ }
318
358
  }
319
359
 
320
360
  #[cfg(unix)]
@@ -334,15 +374,112 @@ mod tests {
334
374
  result
335
375
  }
336
376
 
377
+ #[cfg(unix)]
378
+ fn safe_fixture_sidecar(path: &Path, expected: &str) -> &'static str {
379
+ match std::fs::read_to_string(path) {
380
+ Ok(text) if text == expected => "present_expected",
381
+ Ok(_) => "present_unexpected",
382
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => "missing",
383
+ Err(_) => "unavailable",
384
+ }
385
+ }
386
+
387
+ #[cfg(unix)]
388
+ fn safe_runner_fixture_observation(path: &Path) -> String {
389
+ format!(
390
+ "fixture_entered={} fixture_completed={} fixture_count={}",
391
+ safe_fixture_sidecar(&path.with_extension("entered"), "1\n"),
392
+ safe_fixture_sidecar(&path.with_extension("completed"), "1\n"),
393
+ safe_fixture_sidecar(&path.with_extension("count"), "1:--list-models\n"),
394
+ )
395
+ }
396
+
397
+ #[cfg(unix)]
398
+ fn safe_runner_observation(
399
+ elapsed: Duration,
400
+ observation: &crate::lifecycle::launch::pi_mcp::PiCatalogTestObservation,
401
+ ) -> String {
402
+ format!(
403
+ "elapsed_ms={} spawn_count={} argv={:?} parent_pid={:?} parent_exit_success={:?} parent_exit_code={:?} reader_timeout={}",
404
+ elapsed.as_millis(),
405
+ observation.spawn_count,
406
+ observation.argv,
407
+ observation.parent_pid,
408
+ observation.parent_exit_success,
409
+ observation.parent_exit_code,
410
+ observation.reader_timeout,
411
+ )
412
+ }
413
+
414
+ #[cfg(unix)]
415
+ fn safe_models_result_class(result: &CmdResult) -> &'static str {
416
+ let CmdOutput::Human(text) = &result.output else {
417
+ return "non_human_output";
418
+ };
419
+ let Ok(value) = serde_json::from_str::<Value>(text) else {
420
+ return "non_json_output";
421
+ };
422
+ match value.get("ok").and_then(Value::as_bool) {
423
+ Some(true) => "success_json",
424
+ Some(false) => match value.get("error").and_then(Value::as_str) {
425
+ Some(error) if error.contains("unavailable") => "runner_unavailable",
426
+ Some(error) if error.contains("timed out") => "runner_timeout",
427
+ Some(error) if error.contains("command failed") => "runner_exit",
428
+ Some(error) if error.contains("could not be read") => "reader_error",
429
+ Some(error) if error.contains("exceeds") => "reader_oversize",
430
+ Some(error) if error.contains("catalog") => "parser_catalog_error",
431
+ Some(_) => "failure_json",
432
+ None => "failure_json_missing_error",
433
+ },
434
+ None => "json_missing_ok",
435
+ }
436
+ }
437
+
438
+ #[cfg(unix)]
439
+ fn safe_models_observation(
440
+ elapsed: Duration,
441
+ result: &CmdResult,
442
+ observation: &crate::lifecycle::launch::pi_mcp::PiCatalogTestObservation,
443
+ ) -> String {
444
+ format!(
445
+ "class={} elapsed_ms={} parent_pid={:?} parent_exit_success={:?} parent_exit_code={:?} reader_timeout={}",
446
+ safe_models_result_class(result),
447
+ elapsed.as_millis(),
448
+ observation.parent_pid,
449
+ observation.parent_exit_success,
450
+ observation.parent_exit_code,
451
+ observation.reader_timeout,
452
+ )
453
+ }
454
+
337
455
  #[cfg(unix)]
338
456
  #[test]
339
457
  fn runner_invokes_exact_argv_once_and_drains() {
340
- let path = fixture(
458
+ let path = fixture_with_sidecars(
341
459
  "test \"$1\" = --list-models && echo provider model && echo openai-codex gpt-5.6-sol",
342
460
  );
343
- assert!(run_catalog(&path, Duration::from_secs(1), 1024)
344
- .unwrap()
345
- .starts_with(b"provider model"));
461
+ let receipt = native_timeout_receipt();
462
+ assert!(!receipt.exists());
463
+ let started = Instant::now();
464
+ let (result, observation) =
465
+ crate::lifecycle::launch::pi_mcp::with_pi_catalog_test_observation(
466
+ &receipt,
467
+ None,
468
+ || run_catalog(&path, Duration::from_secs(1), 1024),
469
+ );
470
+ let elapsed = started.elapsed();
471
+ let fixture_observation = safe_runner_fixture_observation(&path);
472
+ assert!(
473
+ result
474
+ .as_ref()
475
+ .is_ok_and(|bytes| bytes.starts_with(b"provider model")),
476
+ "runner observation: {}; {}",
477
+ safe_runner_observation(elapsed, &observation),
478
+ fixture_observation
479
+ );
480
+ let _ = result.unwrap();
481
+ let _ = std::fs::remove_file(&receipt);
482
+ let _ = std::fs::remove_file(format!("{}.sock", receipt.display()));
346
483
  assert_eq!(
347
484
  std::fs::read_to_string(path.with_extension("count")).unwrap(),
348
485
  "1:--list-models\n"
@@ -350,28 +487,160 @@ mod tests {
350
487
  cleanup_fixture(&path);
351
488
  }
352
489
 
490
+ #[cfg(unix)]
491
+ const NATIVE_STAGE_KEYS: [&str; 5] = [
492
+ "parent_entry_ms",
493
+ "parent_before_child_spawn_ms",
494
+ "parent_after_child_spawn_ms",
495
+ "parent_before_exit_ms",
496
+ "test_spawn_return_ms",
497
+ ];
498
+
499
+ #[cfg(unix)]
500
+ // Match the native helper's UNIX epoch millisecond clock exactly.
501
+ fn append_native_stage(path: &Path, key: &str) {
502
+ if !NATIVE_STAGE_KEYS.contains(&key) {
503
+ return;
504
+ }
505
+ let Some(milliseconds) = std::time::SystemTime::now()
506
+ .duration_since(std::time::UNIX_EPOCH)
507
+ .ok()
508
+ .map(|duration| duration.as_millis())
509
+ else {
510
+ return;
511
+ };
512
+ let Ok(mut file) = std::fs::OpenOptions::new()
513
+ .create(true)
514
+ .append(true)
515
+ .open(path)
516
+ else {
517
+ return;
518
+ };
519
+ let _ = writeln!(file, "{key}={milliseconds}");
520
+ }
521
+
522
+ #[cfg(unix)]
523
+ fn safe_native_stage(path: &Path) -> String {
524
+ let (read_state, text) = match std::fs::read_to_string(path) {
525
+ Ok(text) => ("ok", Some(text)),
526
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => ("missing", None),
527
+ Err(_) => ("unavailable", None),
528
+ };
529
+ let mut fields = BTreeMap::new();
530
+ if let Some(ref text) = text {
531
+ for line in text.lines() {
532
+ let Some((key, value)) = line.split_once('=') else {
533
+ continue;
534
+ };
535
+ if NATIVE_STAGE_KEYS.contains(&key)
536
+ && value.len() <= 20
537
+ && value.bytes().all(|byte| byte.is_ascii_digit())
538
+ {
539
+ fields.entry(key).or_insert(value);
540
+ }
541
+ }
542
+ }
543
+ let field = |key: &str| fields.get(key).copied().unwrap_or("unknown");
544
+ format!(
545
+ "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={}",
546
+ field("parent_entry_ms"),
547
+ field("parent_before_child_spawn_ms"),
548
+ field("parent_after_child_spawn_ms"),
549
+ field("parent_before_exit_ms"),
550
+ field("test_spawn_return_ms"),
551
+ )
552
+ }
553
+
554
+ #[cfg(unix)]
555
+ fn wait_for_native_parent_exit(
556
+ child: &mut std::process::Child,
557
+ stage: &Path,
558
+ ) -> std::process::ExitStatus {
559
+ let deadline = Instant::now() + Duration::from_millis(500);
560
+ loop {
561
+ match child.try_wait() {
562
+ Ok(Some(status)) => return status,
563
+ Ok(None) if Instant::now() < deadline => {
564
+ std::thread::sleep(Duration::from_millis(1));
565
+ }
566
+ Ok(None) => {
567
+ let before_kill = safe_native_stage(stage);
568
+ let _ = child.kill();
569
+ let _ = child.wait();
570
+ let after_wait = safe_native_stage(stage);
571
+ panic!(
572
+ "native parent did not exit before test setup deadline; stage_before_kill={before_kill}; stage_after_wait={after_wait}"
573
+ );
574
+ }
575
+ Err(_) => {
576
+ let before_kill = safe_native_stage(stage);
577
+ let _ = child.kill();
578
+ let _ = child.wait();
579
+ let after_wait = safe_native_stage(stage);
580
+ panic!(
581
+ "native parent status could not be observed; stage_before_kill={before_kill}; stage_after_wait={after_wait}"
582
+ );
583
+ }
584
+ }
585
+ }
586
+ }
587
+
588
+ /// Read/drain seam only: public `run_catalog` spawn coverage remains in the
589
+ /// normal success and fail-closed tests.
353
590
  #[cfg(unix)]
354
591
  #[test]
355
- fn runner_timeout_is_bounded_when_descendant_keeps_stdout() {
592
+ fn reader_deadline_is_bounded_after_parent_exit_with_descendant_pipe() {
356
593
  let path = native_timeout_fixture();
357
594
  let receipt = native_timeout_receipt();
595
+ let stage = native_timeout_stage_receipt(&receipt);
358
596
  assert!(!receipt.exists());
597
+ assert!(!stage.exists());
598
+ let mut parent = std::process::Command::new(&path)
599
+ .arg("--list-models")
600
+ .env("TEAM_AGENT_MODELS_TIMEOUT_RECEIPT", &receipt)
601
+ .env("TEAM_AGENT_MODELS_TIMEOUT_STAGE_RECEIPT", &stage)
602
+ .env("TEAM_AGENT_MODELS_TIMEOUT_MODE", "descendant")
603
+ .stdin(std::process::Stdio::null())
604
+ .stdout(std::process::Stdio::piped())
605
+ .stderr(std::process::Stdio::null())
606
+ .spawn()
607
+ .unwrap();
608
+ append_native_stage(&stage, "test_spawn_return_ms");
609
+ let parent_pid = parent.id();
610
+ let parent_status = wait_for_native_parent_exit(&mut parent, &stage);
611
+ let stdout = parent.stdout.take().expect("native parent stdout pipe");
359
612
  let started = Instant::now();
360
613
  let (result, observation) =
361
614
  crate::lifecycle::launch::pi_mcp::with_pi_catalog_test_observation(
362
615
  &receipt,
363
616
  Some("descendant"),
364
- || run_catalog(&path, Duration::from_millis(40), 1024),
617
+ || {
618
+ crate::lifecycle::launch::pi_mcp::run_pi_catalog_after_parent_exit_for_test(
619
+ stdout,
620
+ parent_pid,
621
+ parent_status,
622
+ Duration::from_millis(40),
623
+ 1024,
624
+ )
625
+ },
365
626
  );
366
- assert!(started.elapsed() < Duration::from_millis(500));
627
+ let elapsed = started.elapsed();
628
+ assert!(
629
+ elapsed < Duration::from_millis(500),
630
+ "runner observation: {}",
631
+ safe_runner_observation(elapsed, &observation)
632
+ );
367
633
  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));
634
+ assert_eq!(
635
+ observation.parent_exit_success,
636
+ Some(true),
637
+ "runner observation: {}",
638
+ safe_runner_observation(elapsed, &observation)
639
+ );
371
640
  assert_eq!(observation.parent_exit_code, Some(0));
372
641
  assert!(observation.reader_timeout);
373
642
 
374
- let fields = read_native_timeout_receipt(&receipt);
643
+ let fields = wait_native_timeout_receipt(&receipt);
375
644
  assert_eq!(fields["parent_started_once"], "1");
376
645
  assert_eq!(fields["parent_argv_exact"], "1");
377
646
  assert_eq!(fields["child_spawn_attempts"], "1");
@@ -384,6 +653,7 @@ mod tests {
384
653
  );
385
654
  assert_eq!(fields["child_pid"], fields["descendant_pid"]);
386
655
  let _ = std::fs::remove_file(&receipt);
656
+ let _ = std::fs::remove_file(&stage);
387
657
  let _ = std::fs::remove_file(format!("{}.sock", receipt.display()));
388
658
  }
389
659
 
@@ -398,16 +668,72 @@ mod tests {
398
668
  search: Some("sol".into()),
399
669
  json: true,
400
670
  };
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")
671
+ let receipt = native_timeout_receipt();
672
+ assert!(!receipt.exists());
673
+ let started = Instant::now();
674
+ let (result, observation) =
675
+ crate::lifecycle::launch::pi_mcp::with_pi_catalog_test_observation(
676
+ &receipt,
677
+ None,
678
+ || cmd_models_with(&args, &path, Duration::from_secs(1), 1024),
679
+ );
680
+ let elapsed = started.elapsed();
681
+ let result = match result {
682
+ Ok(result) => result,
683
+ Err(_) => panic!(
684
+ "command boundary returned a CLI error; class=cli_error elapsed_ms={} parent_pid={:?} parent_exit_success={:?} parent_exit_code={:?} reader_timeout={}",
685
+ elapsed.as_millis(),
686
+ observation.parent_pid,
687
+ observation.parent_exit_success,
688
+ observation.parent_exit_code,
689
+ observation.reader_timeout,
690
+ ),
404
691
  };
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);
692
+ let CmdOutput::Human(text) = &result.output else {
693
+ panic!(
694
+ "expected JSON projection; {}",
695
+ safe_models_observation(elapsed, &result, &observation)
696
+ )
697
+ };
698
+ let value: Value = serde_json::from_str(text).unwrap_or_else(|_| {
699
+ panic!(
700
+ "expected valid JSON projection; {}",
701
+ safe_models_observation(elapsed, &result, &observation)
702
+ )
703
+ });
704
+ assert_eq!(
705
+ value["schema_version"],
706
+ "models.v1",
707
+ "{}",
708
+ safe_models_observation(elapsed, &result, &observation)
709
+ );
710
+ assert_eq!(
711
+ value["models"][0]["role_model"],
712
+ "openai-codex/gpt-5.6-sol",
713
+ "{}",
714
+ safe_models_observation(elapsed, &result, &observation)
715
+ );
716
+ assert_eq!(
717
+ value["models"][0]["current"],
718
+ false,
719
+ "{}",
720
+ safe_models_observation(elapsed, &result, &observation)
721
+ );
722
+ assert_eq!(
723
+ value["auth"],
724
+ "ok",
725
+ "{}",
726
+ safe_models_observation(elapsed, &result, &observation)
727
+ );
728
+ let model_count = value["models"].as_array().map(|models| models.len());
729
+ assert_eq!(
730
+ model_count,
731
+ Some(1),
732
+ "{}",
733
+ safe_models_observation(elapsed, &result, &observation)
734
+ );
735
+ let _ = std::fs::remove_file(&receipt);
736
+ let _ = std::fs::remove_file(format!("{}.sock", receipt.display()));
411
737
  let human = cmd_models_with(
412
738
  &ModelsArgs {
413
739
  provider: "pi".into(),
@@ -441,12 +767,16 @@ mod tests {
441
767
  #[test]
442
768
  fn runner_fails_closed_for_nonzero_oversize_timeout_and_unavailable() {
443
769
  let fail = native_timeout_fixture();
770
+ let started = Instant::now();
444
771
  let (failed_result, fail_observation) = with_native_mode("exit7", || {
445
772
  run_catalog(&fail, Duration::from_secs(1), 1024)
446
773
  });
774
+ let elapsed = started.elapsed();
447
775
  assert_eq!(
448
776
  failed_result.unwrap_err(),
449
- "Pi model catalog command failed"
777
+ "Pi model catalog command failed",
778
+ "runner observation: {}",
779
+ safe_runner_observation(elapsed, &fail_observation)
450
780
  );
451
781
  assert_eq!(fail_observation.spawn_count, 1);
452
782
  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));