@team-agent/installer 0.4.6 → 0.4.8

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 (29) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +33 -1
  4. package/crates/team-agent/src/cli/status_port.rs +119 -66
  5. package/crates/team-agent/src/cli/tests/status_send.rs +74 -48
  6. package/crates/team-agent/src/coordinator/tick.rs +2 -21
  7. package/crates/team-agent/src/leader/helpers.rs +1 -22
  8. package/crates/team-agent/src/lifecycle/launch.rs +1 -11
  9. package/crates/team-agent/src/lifecycle/profile_launch.rs +25 -11
  10. package/crates/team-agent/src/lifecycle/restart/agent.rs +27 -1
  11. package/crates/team-agent/src/lifecycle/restart/common.rs +20 -26
  12. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +30 -8
  13. package/crates/team-agent/src/lifecycle/restart/selection.rs +67 -16
  14. package/crates/team-agent/src/lifecycle/restart.rs +1 -0
  15. package/crates/team-agent/src/lifecycle/tests/core.rs +99 -0
  16. package/crates/team-agent/src/messaging/delivery.rs +113 -0
  17. package/crates/team-agent/src/provider/adapter.rs +18 -372
  18. package/crates/team-agent/src/provider/adapters/claude.rs +106 -0
  19. package/crates/team-agent/src/provider/adapters/codex.rs +117 -0
  20. package/crates/team-agent/src/provider/adapters/copilot.rs +124 -0
  21. package/crates/team-agent/src/provider/adapters/fake.rs +17 -0
  22. package/crates/team-agent/src/provider/adapters/mod.rs +27 -0
  23. package/crates/team-agent/src/provider/mod.rs +6 -0
  24. package/crates/team-agent/src/provider/session/capture.rs +131 -1
  25. package/crates/team-agent/src/provider/wire.rs +139 -0
  26. package/crates/team-agent/src/state/paths.rs +8 -4
  27. package/crates/team-agent/src/state/persist.rs +17 -0
  28. package/package.json +4 -4
  29. package/skills/team-agent/SKILL.md +6 -0
package/Cargo.lock CHANGED
@@ -566,7 +566,7 @@ dependencies = [
566
566
 
567
567
  [[package]]
568
568
  name = "team-agent"
569
- version = "0.4.6"
569
+ version = "0.4.8"
570
570
  dependencies = [
571
571
  "anyhow",
572
572
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent"]
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.4.6"
12
+ version = "0.4.8"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -226,6 +226,33 @@ pub fn cmd_status_for_team(args: &StatusArgs, team: Option<&str>) -> Result<CmdR
226
226
  "status --summary does not accept an agent argument".to_string(),
227
227
  ));
228
228
  }
229
+ // S4QR-001 (0.4.8): selected-team ambiguity gate. status is a selected-team
230
+ // command: when the workspace has 2+ alive teams and no --team was passed,
231
+ // refuse instead of silently defaulting to the active team. Uses the same
232
+ // CommandScope::resolve helper as refuse_if_multi_alive_team_missing_scope
233
+ // (cli/emit.rs:964-979) so the destructive-command ambiguity gate and the
234
+ // selected-team-read ambiguity gate share a single source of truth.
235
+ if team.is_none() {
236
+ let scope = crate::state::paths::CommandScope::resolve(&args.workspace, None);
237
+ if scope.is_ambiguous() {
238
+ let candidates: Vec<String> = scope.candidates().to_vec();
239
+ let message = format!(
240
+ "status: workspace has multiple alive teams ({}); pass `--team <key>` to choose one",
241
+ candidates.join(", ")
242
+ );
243
+ if args.json {
244
+ let payload = serde_json::json!({
245
+ "ok": false,
246
+ "status": "refused",
247
+ "reason": "team_target_ambiguous",
248
+ "candidates": candidates,
249
+ "message": message,
250
+ });
251
+ return Ok(CmdResult::from_json(payload, args.json));
252
+ }
253
+ return Err(CliError::Usage(message));
254
+ }
255
+ }
229
256
  let selected = match crate::state::selector::resolve_active_team(
230
257
  &args.workspace,
231
258
  team,
@@ -240,11 +267,16 @@ pub fn cmd_status_for_team(args: &StatusArgs, team: Option<&str>) -> Result<CmdR
240
267
  }
241
268
  };
242
269
  if args.summary {
270
+ // 0.4.x compact slimming: `--summary` renders the five-line triage
271
+ // text via `format_status_summary`, which needs the full payload
272
+ // fields (coordinator, leader_receiver, agent_health, queued_messages,
273
+ // latest_results). The slim compact projection drops those, so the
274
+ // summary path must request the full payload (compact=false).
243
275
  let value = status_port::status_scoped(
244
276
  &selected.run_workspace,
245
277
  &selected.state,
246
278
  Some(&selected.team_key),
247
- true,
279
+ false,
248
280
  false,
249
281
  )?;
250
282
  return Ok(CmdResult::human(append_reminder(
@@ -601,39 +601,127 @@ use rusqlite::params;
601
601
  Ok(Value::Array(values))
602
602
  }
603
603
 
604
+ /// 0.4.x: slim default compact payload — exactly 7 top-level fields.
605
+ /// Diagnostic detail moves to `--detail`. Plan:
606
+ /// /Users/alauda/Documents/code/team-agent-public/.team/artifacts/status-compact-plan.md
604
607
  fn compact_status(full: Value) -> Value {
608
+ let not_ready = compact_not_ready(&full);
609
+ let ready = compact_ready(&full, &not_ready);
605
610
  json!({
611
+ "ok": true,
606
612
  "team": full.get("team").cloned().unwrap_or(Value::Null),
607
613
  "session_name": full.get("session_name").cloned().unwrap_or(Value::Null),
608
- "leader_topology": full.get("leader_topology").cloned().unwrap_or_else(|| json!("managed")),
609
- "is_external_leader": full.get("is_external_leader").cloned().unwrap_or(Value::Bool(false)),
610
614
  "leader_attach_command": full.get("leader_attach_command").cloned().unwrap_or(Value::Null),
611
- "leader_client": full.get("leader_client").cloned().unwrap_or(Value::Null),
612
- "tmux_session_present": full.get("tmux_session_present").cloned().unwrap_or(Value::Bool(false)),
613
- "all_spawned": full.get("all_spawned").cloned().unwrap_or(Value::Bool(false)),
614
- "all_attached_receiver": full.get("all_attached_receiver").cloned().unwrap_or(Value::Bool(true)),
615
- "all_resumable_have_session": full.get("all_resumable_have_session").cloned().unwrap_or(Value::Bool(true)),
616
- "session_capture_complete": full.get("session_capture_complete").cloned().unwrap_or(Value::Bool(true)),
617
- "session_capture_incomplete": full.get("session_capture_incomplete").cloned().unwrap_or(Value::Bool(false)),
618
- "incomplete_session_capture_agents": full.get("incomplete_session_capture_agents").cloned().unwrap_or_else(|| json!([])),
619
- "pending_session_agent_ids": full.get("pending_session_agent_ids").cloned().unwrap_or_else(|| json!([])),
620
- "leader_receiver": compact_object(full.get("leader_receiver"), &[
621
- "status", "provider", "mode", "session_name", "window_name", "pane_id", "pane_current_command",
622
- ]),
615
+ "ready": ready,
616
+ "not_ready": not_ready,
623
617
  "agents": compact_agents(full.get("agents")),
624
- "agent_health": full.get("agent_health").cloned().unwrap_or_else(|| json!({})),
625
- "tasks": compact_tasks(full.get("tasks")),
626
- "messages": full.get("messages").cloned().unwrap_or_else(|| json!({})),
627
- "queued_messages": take_array(full.get("queued_messages"), 8),
628
- "results": full.get("results").cloned().unwrap_or_else(|| json!({})),
629
- "latest_results": take_array(full.get("latest_results"), 5),
630
- "readiness": full.get("readiness").cloned().unwrap_or_else(|| json!({})),
631
- "coordinator": compact_object(full.get("coordinator"), &["status", "pid", "metadata_ok", "schema_ok"]),
632
- "reminder": full.get("reminder").cloned().unwrap_or_else(|| json!(crate::cli::STATUS_REMINDER)),
633
- "last_events": take_array_tail(full.get("last_events"), 10),
634
618
  })
635
619
  }
636
620
 
621
+ /// Synthesized readiness boolean for the slim payload. Stricter than the
622
+ /// raw `readiness.ready` because it also folds in coordinator + schema +
623
+ /// tmux session presence so operators don't need to read separate booleans.
624
+ fn compact_ready(full: &Value, not_ready: &Value) -> bool {
625
+ not_ready.is_null()
626
+ && full
627
+ .get("readiness")
628
+ .and_then(|r| r.get("ready"))
629
+ .and_then(Value::as_bool)
630
+ .unwrap_or(false)
631
+ && full
632
+ .get("coordinator")
633
+ .and_then(|c| c.get("status"))
634
+ .and_then(Value::as_str)
635
+ .is_some_and(|s| s == "running" || s == "ok")
636
+ && full
637
+ .get("coordinator")
638
+ .and_then(|c| c.get("schema_ok"))
639
+ .and_then(Value::as_bool)
640
+ .unwrap_or(true)
641
+ }
642
+
643
+ /// Returns `Value::Null` when fully ready, otherwise an object:
644
+ /// `{"reasons": [...], "agents": [...]}` listing every gating issue.
645
+ fn compact_not_ready(full: &Value) -> Value {
646
+ let reasons = not_ready_reasons(full);
647
+ if reasons.is_empty() {
648
+ return Value::Null;
649
+ }
650
+ let agents = full
651
+ .get("incomplete_session_capture_agents")
652
+ .and_then(Value::as_array)
653
+ .cloned()
654
+ .or_else(|| {
655
+ full.get("pending_session_agent_ids")
656
+ .and_then(Value::as_array)
657
+ .cloned()
658
+ })
659
+ .unwrap_or_default();
660
+ let mut obj = Map::new();
661
+ obj.insert(
662
+ "reasons".to_string(),
663
+ Value::Array(reasons.into_iter().map(Value::String).collect()),
664
+ );
665
+ obj.insert("agents".to_string(), Value::Array(agents));
666
+ Value::Object(obj)
667
+ }
668
+
669
+ fn not_ready_reasons(full: &Value) -> Vec<String> {
670
+ let mut reasons = Vec::new();
671
+ let coord = full.get("coordinator");
672
+ let coord_status = coord
673
+ .and_then(|c| c.get("status"))
674
+ .and_then(Value::as_str)
675
+ .unwrap_or("");
676
+ if coord_status != "running" && coord_status != "ok" {
677
+ reasons.push("coordinator_not_running".to_string());
678
+ }
679
+ if coord
680
+ .and_then(|c| c.get("schema_ok"))
681
+ .and_then(Value::as_bool)
682
+ == Some(false)
683
+ {
684
+ reasons.push("coordinator_schema_not_ok".to_string());
685
+ }
686
+ if full
687
+ .get("tmux_session_present")
688
+ .and_then(Value::as_bool)
689
+ == Some(false)
690
+ {
691
+ reasons.push("tmux_session_missing".to_string());
692
+ }
693
+ let readiness = full.get("readiness");
694
+ if readiness
695
+ .and_then(|r| r.get("all_spawned"))
696
+ .and_then(Value::as_bool)
697
+ == Some(false)
698
+ {
699
+ reasons.push("workers_not_spawned".to_string());
700
+ }
701
+ if readiness
702
+ .and_then(|r| r.get("all_attached_receiver"))
703
+ .and_then(Value::as_bool)
704
+ == Some(false)
705
+ {
706
+ reasons.push("leader_receiver_unbound".to_string());
707
+ }
708
+ if readiness
709
+ .and_then(|r| r.get("session_capture_complete"))
710
+ .and_then(Value::as_bool)
711
+ == Some(false)
712
+ {
713
+ reasons.push("session_capture_incomplete".to_string());
714
+ }
715
+ if readiness
716
+ .and_then(|r| r.get("awaiting_trust_prompt"))
717
+ .and_then(Value::as_bool)
718
+ == Some(true)
719
+ {
720
+ reasons.push("awaiting_trust_prompt".to_string());
721
+ }
722
+ reasons
723
+ }
724
+
637
725
  fn compact_agents(value: Option<&Value>) -> Value {
638
726
  let Some(Value::Object(input)) = value else {
639
727
  return json!({});
@@ -645,56 +733,21 @@ use rusqlite::params;
645
733
  Value::Object(out)
646
734
  }
647
735
 
648
- fn compact_agent_state(agent_id: &str, agent: &Value) -> Value {
736
+ /// 0.4.x: agent rows in the slim payload have exactly 4 fields. agent_id
737
+ /// is no longer copied in — the map key already carries it. Diagnostic
738
+ /// fields (model, tmux_window_present, session_id, captured_via,
739
+ /// attribution_confidence, display, interacted) move to `--detail`.
740
+ /// `activity` + `last_output_at` are preserved (RM-039-STAT-001).
741
+ fn compact_agent_state(_agent_id: &str, agent: &Value) -> Value {
649
742
  let Some(input) = agent.as_object() else {
650
743
  return json!({});
651
744
  };
652
745
  let mut out = Map::new();
653
- out.insert(
654
- "agent_id".to_string(),
655
- input
656
- .get("agent_id")
657
- .cloned()
658
- .unwrap_or_else(|| Value::String(agent_id.to_string())),
659
- );
660
- for key in [
661
- "status",
662
- "provider",
663
- "model",
664
- "tmux_window_present",
665
- "session_id",
666
- "captured_via",
667
- "attribution_confidence",
668
- // RM-039-STAT-001 fix (real-machine evidence 2026-06-22): the
669
- // coordinator-tick activity classifier writes activity (status,
670
- // confidence, rationale) to the top-level agent state, but the
671
- // status --json compact projection dropped it. Operators saw
672
- // "status: running" with no working/idle signal even when the
673
- // pane was clearly producing output. Add the classifier output
674
- // here so the compact form surfaces it.
675
- // last_output_at is the timestamp the classifier advanced when
676
- // the scrollback digest changed; keeping it adjacent to activity
677
- // gives the operator a one-glance "is something moving" view.
678
- // interacted is enriched in enrich_agents (true|false|never)
679
- // and is the canonical "leader has ever sent this worker a
680
- // message" signal — also needed for status sanity at a glance.
681
- "activity",
682
- "last_output_at",
683
- "interacted",
684
- ] {
746
+ for key in ["status", "provider", "activity", "last_output_at"] {
685
747
  if let Some(value) = input.get(key) {
686
748
  out.insert(key.to_string(), value.clone());
687
749
  }
688
750
  }
689
- if let Some(display) = input.get("display") {
690
- let compact_display = compact_object(
691
- Some(display),
692
- &["backend", "status", "workspace_window", "pane_id", "pid", "pids", "reason"],
693
- );
694
- if compact_display.as_object().is_some_and(|obj| !obj.is_empty()) {
695
- out.insert("display".to_string(), compact_display);
696
- }
697
- }
698
751
  Value::Object(out)
699
752
  }
700
753
 
@@ -36,7 +36,7 @@ use super::*;
36
36
  // projection MUST NOT collapse them.
37
37
  // =========================================================================
38
38
  #[test]
39
- fn rm039_stat001_compact_status_preserves_activity_last_output_and_interacted() {
39
+ fn rm039_stat001_compact_status_preserves_activity_and_last_output() {
40
40
  let ws = seed_status_workspace();
41
41
  let mut state = crate::state::persist::load_runtime_state(&ws).unwrap();
42
42
  if let Some(agents) = state
@@ -99,13 +99,11 @@ use super::*;
99
99
  Some("2026-06-22T02:52:30+00:00"),
100
100
  "compact projection must preserve `last_output_at` for the \"is something moving\" view"
101
101
  );
102
- // enrich_agents injects `interacted` from first_send_at; the
103
- // compact path must carry it (operators read it as a one-glance
104
- // \"has the leader ever sent this worker a message?\" signal).
105
- assert_eq!(
106
- agent.get("interacted").and_then(serde_json::Value::as_str),
107
- Some("2026-01-01T00:00:00Z"),
108
- "compact projection must preserve `interacted` from enrich_agents"
102
+ // 0.4.x compact slim: `interacted` moves to --detail; the 4-field
103
+ // compact agent row keeps only status/provider/activity/last_output_at.
104
+ assert!(
105
+ agent.get("interacted").is_none(),
106
+ "0.4.x: compact projection drops `interacted` (moved to --detail)"
109
107
  );
110
108
  let _ = std::fs::remove_dir_all(&ws);
111
109
  }
@@ -215,23 +213,37 @@ use super::*;
215
213
  #[test]
216
214
  fn status_port_status_compact_json_shape_against_seeded_fixture() {
217
215
  // cmd_status json branch (detail=false) delegates status_port::status(compact=true).
218
- // compact_status (status/compact.py:8-37) projects to a STABLE key set; assert the
219
- // load-bearing keys survive and `last_events` is bounded (compact truncates events).
216
+ // 0.4.x compact slim: exactly 7 top-level fields; diagnostics moved
217
+ // to --detail. Plan: .team/artifacts/status-compact-plan.md.
220
218
  let ws = seed_status_workspace();
221
219
  let v = status_port::status(&ws, /*compact=*/ true, /*detail=*/ false)
222
220
  .expect("seeded fixture status should project a value");
223
221
  let obj = v.as_object().expect("--json status is a dict");
224
- // compact_status's exact top-level key set (compact.py:9-37):
225
- for key in [
222
+ // Exactly these 7 keys, no more.
223
+ let expected: std::collections::BTreeSet<&str> = [
224
+ "ok",
226
225
  "team",
227
226
  "session_name",
227
+ "leader_attach_command",
228
+ "ready",
229
+ "not_ready",
230
+ "agents",
231
+ ]
232
+ .iter()
233
+ .copied()
234
+ .collect();
235
+ let actual: std::collections::BTreeSet<&str> = obj.keys().map(String::as_str).collect();
236
+ assert_eq!(
237
+ actual, expected,
238
+ "0.4.x compact must expose exactly 7 keys; got {actual:?}"
239
+ );
240
+ // Diagnostic keys must NOT leak into the default compact payload.
241
+ for forbidden in [
228
242
  "leader_topology",
229
243
  "is_external_leader",
230
- "leader_attach_command",
231
244
  "leader_client",
232
245
  "tmux_session_present",
233
246
  "leader_receiver",
234
- "agents",
235
247
  "agent_health",
236
248
  "tasks",
237
249
  "messages",
@@ -239,23 +251,20 @@ use super::*;
239
251
  "results",
240
252
  "latest_results",
241
253
  "coordinator",
254
+ "readiness",
242
255
  "reminder",
243
256
  "last_events",
244
257
  ] {
245
- assert!(obj.contains_key(key), "compact status missing key `{key}`");
258
+ assert!(
259
+ !obj.contains_key(forbidden),
260
+ "0.4.x: compact must NOT contain diagnostic key `{forbidden}` (--detail only)"
261
+ );
246
262
  }
247
- assert_eq!(
248
- obj.get("reminder").and_then(|v| v.as_str()),
249
- Some(crate::cli::STATUS_REMINDER)
250
- );
251
263
  // seeded agent surfaces through the projection.
252
264
  assert!(
253
265
  obj["agents"].as_object().unwrap().contains_key("a1"),
254
266
  "seeded agent a1 must appear in compact agents projection"
255
267
  );
256
- // compact bounds: queued_messages[:8] and latest_results[:5] -> arrays.
257
- assert!(obj["queued_messages"].is_array());
258
- assert!(obj["latest_results"].as_array().unwrap().len() <= 5);
259
268
  let _ = std::fs::remove_dir_all(&ws);
260
269
  }
261
270
 
@@ -291,14 +300,17 @@ use super::*;
291
300
  }
292
301
  crate::state::persist::save_runtime_state(&ws, &state).unwrap();
293
302
 
294
- let v = status_port::status(&ws, /*compact=*/ true, /*detail=*/ false).expect("status");
295
-
296
- assert_eq!(v["leader_topology"], json!("managed"));
297
- assert_eq!(v["is_external_leader"], json!(false));
298
- let attach = v["leader_attach_command"]
303
+ // 0.4.x: leader_topology / is_external_leader moved to --detail.
304
+ // leader_attach_command stays in the slim compact payload.
305
+ let slim = status_port::status(&ws, /*compact=*/ true, /*detail=*/ false).expect("status");
306
+ let attach = slim["leader_attach_command"]
299
307
  .as_str()
300
- .expect("managed status includes attach command");
308
+ .expect("compact still includes leader_attach_command");
301
309
  assert!(attach.contains("attach -t team-current:leader"), "{attach}");
310
+
311
+ let detail = status_port::status(&ws, /*compact=*/ false, /*detail=*/ true).expect("status detail");
312
+ assert_eq!(detail["leader_topology"], json!("managed"));
313
+ assert_eq!(detail["is_external_leader"], json!(false));
302
314
  let _ = std::fs::remove_dir_all(&ws);
303
315
  }
304
316
 
@@ -365,33 +377,48 @@ use super::*;
365
377
  }
366
378
  crate::state::persist::save_runtime_state(&ws, &state).unwrap();
367
379
 
368
- let v = status_port::status(&ws, /*compact=*/ true, /*detail=*/ false).expect("status");
369
-
370
- assert_eq!(v["leader_topology"], json!("managed"));
371
- assert_eq!(v["is_external_leader"], json!(false));
372
- let attach = v["leader_attach_command"]
380
+ // 0.4.x: topology/external markers moved to --detail; compact keeps attach.
381
+ let slim = status_port::status(&ws, /*compact=*/ true, /*detail=*/ false).expect("status");
382
+ let attach = slim["leader_attach_command"]
373
383
  .as_str()
374
384
  .expect("missing marker defaults to managed attach command");
375
385
  assert!(attach.contains("attach -t team-current:leader"), "{attach}");
386
+
387
+ let detail = status_port::status(&ws, /*compact=*/ false, /*detail=*/ true).expect("status detail");
388
+ assert_eq!(detail["leader_topology"], json!("managed"));
389
+ assert_eq!(detail["is_external_leader"], json!(false));
376
390
  let _ = std::fs::remove_dir_all(&ws);
377
391
  }
378
392
 
379
393
  #[test]
380
394
  fn status_port_status_detail_full_keeps_uncompacted_events() {
381
- // cmd_status --json --detail -> status_port::status(compact=false): the FULL dict
382
- // (queries.py:65-79) is returned WITHOUT compact_status truncation. Distinguishing
383
- // invariant: full result preserves `tasks` rows verbatim (not compact_task-projected),
384
- // so the seeded task's full row (incl. fields compact would drop) survives.
395
+ // 0.4.x: --detail (compact=false) preserves ALL diagnostic fields the
396
+ // compact slim payload drops. Pin the must-keep set so future
397
+ // refactors can't accidentally strip detail diagnostics.
385
398
  let ws = seed_status_workspace();
386
399
  let full = status_port::status(&ws, /*compact=*/ false, /*detail=*/ true)
387
400
  .expect("seeded fixture full status should project a value");
388
401
  let compact = status_port::status(&ws, /*compact=*/ true, /*detail=*/ false)
389
402
  .expect("seeded fixture compact status should project a value");
390
- // The CLI-owned invariant: detail=true => compact=false; the two projections MUST
391
- // differ in shape (full carries `messages`/`results` count maps + verbatim tasks).
392
403
  assert_ne!(full, compact, "detail (full) and default (compact) projections must differ");
393
404
  let full_obj = full.as_object().expect("full status is a dict");
394
- assert!(full_obj.contains_key("last_events"), "full status keeps last_events");
405
+ for key in [
406
+ "coordinator",
407
+ "readiness",
408
+ "leader_receiver",
409
+ "agent_health",
410
+ "tasks",
411
+ "messages",
412
+ "queued_messages",
413
+ "results",
414
+ "latest_results",
415
+ "last_events",
416
+ ] {
417
+ assert!(
418
+ full_obj.contains_key(key),
419
+ "0.4.x: --detail must preserve `{key}` (compact slimming escape hatch)"
420
+ );
421
+ }
395
422
  assert_eq!(full_obj["agents"].as_object().unwrap().len(), 1);
396
423
  let _ = std::fs::remove_dir_all(&ws);
397
424
  }
@@ -565,16 +592,15 @@ fn status_noncompact_coordinator_includes_ok() {
565
592
  }
566
593
 
567
594
  #[test]
568
- fn status_compact_coordinator_omits_ok() {
595
+ fn status_compact_omits_coordinator_entirely() {
596
+ // 0.4.x compact slim: the entire `coordinator` block moved to --detail.
597
+ // Compact folds coordinator health into the top-level `ready`/`not_ready`
598
+ // synthesis (`coordinator_not_running` / `coordinator_schema_not_ok`).
569
599
  let ws = seed_status_workspace();
570
600
  let v = status_port::status(&ws, /*compact=*/ true, /*detail=*/ false).expect("status");
571
- let coord = v.get("coordinator").and_then(|c| c.as_object()).expect("coordinator object");
572
- let keys: std::collections::BTreeSet<&str> = coord.keys().map(String::as_str).collect();
573
- let expected: std::collections::BTreeSet<&str> =
574
- ["metadata_ok", "pid", "schema_ok", "status"].into_iter().collect();
575
- assert_eq!(
576
- keys, expected,
577
- "compact coordinator key set must be EXACTLY {{metadata_ok,pid,schema_ok,status}} (no ok/metadata)"
601
+ assert!(
602
+ v.as_object().unwrap().get("coordinator").is_none(),
603
+ "0.4.x: compact must NOT include `coordinator`; reasons fold into not_ready"
578
604
  );
579
605
  let _ = std::fs::remove_dir_all(&ws);
580
606
  }
@@ -1656,16 +1656,7 @@ fn turn_state_wire(state: TurnState) -> &'static str {
1656
1656
  }
1657
1657
  }
1658
1658
 
1659
- fn provider_wire(provider: crate::model::enums::Provider) -> &'static str {
1660
- match provider {
1661
- crate::model::enums::Provider::Claude => "claude",
1662
- crate::model::enums::Provider::ClaudeCode => "claude_code",
1663
- crate::model::enums::Provider::Codex => "codex",
1664
- crate::model::enums::Provider::Copilot => "copilot",
1665
- crate::model::enums::Provider::GeminiCli => "gemini_cli",
1666
- crate::model::enums::Provider::Fake => "fake",
1667
- }
1668
- }
1659
+ use crate::provider::wire::provider_wire;
1669
1660
 
1670
1661
  #[derive(Debug, Clone)]
1671
1662
  struct AbnormalWatchAgent {
@@ -2409,17 +2400,7 @@ fn monotonic_seconds() -> f64 {
2409
2400
  }
2410
2401
  }
2411
2402
 
2412
- fn parse_provider(raw: &str) -> Option<crate::model::enums::Provider> {
2413
- match raw {
2414
- "claude" => Some(crate::model::enums::Provider::Claude),
2415
- "claude_code" => Some(crate::model::enums::Provider::ClaudeCode),
2416
- "codex" => Some(crate::model::enums::Provider::Codex),
2417
- "copilot" => Some(crate::model::enums::Provider::Copilot),
2418
- "gemini_cli" => Some(crate::model::enums::Provider::GeminiCli),
2419
- "fake" => Some(crate::model::enums::Provider::Fake),
2420
- _ => None,
2421
- }
2422
- }
2403
+ use crate::provider::wire::parse_provider;
2423
2404
 
2424
2405
  fn capture_window_target(
2425
2406
  agent: &Value,
@@ -9,28 +9,7 @@ use crate::provider::{Provider, RolloutPath, TurnState};
9
9
 
10
10
  use super::LeaderError;
11
11
 
12
- pub(crate) fn provider_wire(provider: Provider) -> &'static str {
13
- match provider {
14
- Provider::Claude => "claude",
15
- Provider::ClaudeCode => "claude_code",
16
- Provider::Codex => "codex",
17
- Provider::Copilot => "copilot",
18
- Provider::GeminiCli => "gemini_cli",
19
- Provider::Fake => "fake",
20
- }
21
- }
22
-
23
- pub(crate) fn parse_provider(s: &str) -> Option<Provider> {
24
- match s {
25
- "claude" => Some(Provider::Claude),
26
- "claude_code" => Some(Provider::ClaudeCode),
27
- "codex" => Some(Provider::Codex),
28
- "copilot" => Some(Provider::Copilot),
29
- "gemini_cli" => Some(Provider::GeminiCli),
30
- "fake" => Some(Provider::Fake),
31
- _ => None,
32
- }
33
- }
12
+ pub(crate) use crate::provider::wire::{parse_provider, provider_wire};
34
13
 
35
14
  pub(crate) fn turn_state_wire(state: TurnState) -> &'static str {
36
15
  match state {
@@ -2455,17 +2455,7 @@ fn spec_display_backend(spec: &Value) -> DisplayBackend {
2455
2455
  crate::lifecycle::display::resolve_display_backend(requested, None).backend
2456
2456
  }
2457
2457
 
2458
- fn parse_provider(raw: &str) -> Option<Provider> {
2459
- match raw {
2460
- "claude" => Some(Provider::Claude),
2461
- "claude_code" => Some(Provider::ClaudeCode),
2462
- "codex" => Some(Provider::Codex),
2463
- "copilot" => Some(Provider::Copilot),
2464
- "gemini_cli" => Some(Provider::GeminiCli),
2465
- "fake" => Some(Provider::Fake),
2466
- _ => None,
2467
- }
2468
- }
2458
+ use crate::provider::wire::parse_provider;
2469
2459
 
2470
2460
  fn parse_auth_mode(raw: &str) -> Option<AuthMode> {
2471
2461
  match raw {
@@ -419,6 +419,30 @@ fn provider_env_unsets(provider: Provider, auth_mode: AuthMode) -> BTreeSet<Stri
419
419
  let mut unsets = BTreeSet::new();
420
420
  match provider {
421
421
  Provider::Claude | Provider::ClaudeCode => {
422
+ // E57 (0.3.27 P0): unset CLAUDE_CODE_SESSION_ID inherited from the
423
+ // leader's environment. When a Claude leader spawns a Claude worker,
424
+ // the worker inherits the leader's CLAUDE_CODE_SESSION_ID; Claude
425
+ // Code then routes the worker's transcript to the leader's session
426
+ // file (~/.claude/projects/<hash>/<leader-session-uuid>.jsonl),
427
+ // corrupting attribution and transcripts. Always unset so Claude
428
+ // Code generates a fresh session id per worker.
429
+ //
430
+ // S1-CAPTURE (0.4.x P0): also unset the rest of the Claude Code
431
+ // child/team env block. These variables make the spawned process
432
+ // believe it is a CHILD of the parent Claude (CLAUDE_CODE_CHILD_SESSION),
433
+ // a TEAM member (CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS), or otherwise
434
+ // inherit the parent's invocation identity (ENTRYPOINT / EXECPATH /
435
+ // CLAUDECODE marker). Without unsetting these, a Claude worker
436
+ // spawned from a Claude leader is not an INDEPENDENT process — it
437
+ // joins the leader's session tree and its transcript / state /
438
+ // billing are attributed to the leader. CLAUDE_EFFORT is preserved
439
+ // (effort preference does not affect session attribution).
440
+ unsets.insert("CLAUDE_CODE_SESSION_ID".to_string());
441
+ unsets.insert("CLAUDE_CODE_CHILD_SESSION".to_string());
442
+ unsets.insert("CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS".to_string());
443
+ unsets.insert("CLAUDE_CODE_ENTRYPOINT".to_string());
444
+ unsets.insert("CLAUDE_CODE_EXECPATH".to_string());
445
+ unsets.insert("CLAUDECODE".to_string());
422
446
  if auth_mode == AuthMode::CompatibleApi {
423
447
  unsets.insert("ANTHROPIC_API_KEY".to_string());
424
448
  }
@@ -894,17 +918,7 @@ fn effective_profile_or_agent_model<'a>(
894
918
  agent.model.as_deref().or_else(|| profile_model(values))
895
919
  }
896
920
 
897
- pub(crate) fn parse_provider(raw: &str) -> Option<Provider> {
898
- match raw {
899
- "claude" => Some(Provider::Claude),
900
- "claude_code" => Some(Provider::ClaudeCode),
901
- "codex" => Some(Provider::Codex),
902
- "copilot" => Some(Provider::Copilot),
903
- "gemini_cli" => Some(Provider::GeminiCli),
904
- "fake" => Some(Provider::Fake),
905
- _ => None,
906
- }
907
- }
921
+ pub(crate) use crate::provider::wire::parse_provider;
908
922
 
909
923
  pub(crate) fn parse_auth_mode(raw: &str) -> Option<AuthMode> {
910
924
  match raw {