@team-agent/installer 0.5.101 → 0.5.103

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 (49) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/emit.rs +2 -1
  4. package/crates/team-agent/src/cli/models.rs +92 -226
  5. package/crates/team-agent/src/cli/spec.rs +1 -1
  6. package/crates/team-agent/src/cli/tests/main_preserved.rs +0 -74
  7. package/crates/team-agent/src/compiler/tests.rs +20 -23
  8. package/crates/team-agent/src/compiler.rs +49 -65
  9. package/crates/team-agent/src/coordinator/tests/s3_bounded.rs +16 -48
  10. package/crates/team-agent/src/leader/start.rs +1 -14
  11. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +37 -161
  12. package/crates/team-agent/src/lifecycle/launch/spawn.rs +1 -1
  13. package/crates/team-agent/src/lifecycle/restart/common.rs +1 -1
  14. package/crates/team-agent/src/lifecycle/tests/host_cotenant_death_p0_contract.rs +0 -28
  15. package/crates/team-agent/src/lifecycle/tests/pi_compiler_red.rs +11 -9
  16. package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +8 -401
  17. package/crates/team-agent/src/lifecycle/tests/pi_executable_mcp_red.rs +2 -2
  18. package/crates/team-agent/src/lifecycle/worker_command_context.rs +22 -8
  19. package/crates/team-agent/src/mcp_server/helpers.rs +0 -16
  20. package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +2 -7
  21. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +1 -127
  22. package/crates/team-agent/src/mcp_server/mod.rs +7 -12
  23. package/crates/team-agent/src/mcp_server/tests/scoped.rs +1 -132
  24. package/crates/team-agent/src/mcp_server/tests/send.rs +1 -93
  25. package/crates/team-agent/src/mcp_server/tests/tools.rs +0 -61
  26. package/crates/team-agent/src/mcp_server/tests/wire.rs +6 -51
  27. package/crates/team-agent/src/mcp_server/tests.rs +4 -200
  28. package/crates/team-agent/src/mcp_server/tools.rs +5 -396
  29. package/crates/team-agent/src/mcp_server/types.rs +3 -36
  30. package/crates/team-agent/src/mcp_server/wire.rs +2 -223
  31. package/crates/team-agent/src/messaging/leader_receiver.rs +1 -1
  32. package/crates/team-agent/src/messaging/mod.rs +2 -2
  33. package/crates/team-agent/src/messaging/results.rs +11 -42
  34. package/crates/team-agent/src/messaging/scheduler.rs +2 -205
  35. package/crates/team-agent/src/messaging/tests/basic.rs +0 -31
  36. package/crates/team-agent/src/messaging/tests/main_preserved.rs +0 -73
  37. package/crates/team-agent/src/messaging/tests/runtime.rs +0 -26
  38. package/crates/team-agent/src/messaging/types.rs +0 -20
  39. package/crates/team-agent/src/model/spec.rs +14 -18
  40. package/crates/team-agent/src/provider/mod.rs +1 -0
  41. package/crates/team-agent/src/provider/model_catalog.rs +650 -0
  42. package/crates/team-agent/src/state/repository/tests.rs +1 -16
  43. package/crates/team-agent/src/state/repository.rs +1 -33
  44. package/package.json +4 -4
  45. package/skills/team-agent/SKILL.md +5 -5
  46. package/skills/team-agent/command-coverage.json +7 -0
  47. package/skills/team-agent/references/team-agent-operator.md +24 -116
  48. package/skills/team-agent/references/team-in-team.md +4 -19
  49. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +0 -484
@@ -44,48 +44,38 @@ pub struct IgnoredTeamField {
44
44
  pub value: String,
45
45
  }
46
46
 
47
- /// `compiler._read_front_matter` (compiler.py:173-185).
48
- ///
49
- /// Reads `path` (UTF-8). If the text does not start with `"---\n"`, returns
50
- /// `({}, full_text)`. Otherwise splits on the first `"\n---"` after byte 4:
51
- /// unterminated (no closing marker) → `ValidationError "{path}: unterminated
52
- /// front matter"`; the front-matter block is parsed via `simple_yaml.loads`
53
- /// (empty block → `{}`); a non-dict block → `ValidationError "{path}: front
54
- /// matter must be a YAML object"`. The body is everything after the closing
55
- /// marker, `lstrip("\n")` (leading NEWLINES only — not other whitespace).
47
+ /// Read optional YAML front matter; malformed or non-object headers are plain text.
56
48
  pub fn read_front_matter(path: &Path) -> Result<(Value, String), ModelError> {
57
49
  let text = fs::read_to_string(path)
58
50
  .map_err(|e| ModelError::Runtime(format!("{}: {e}", path.display())))?;
59
51
  let text = text.replace("\r\n", "\n").replace('\r', "\n");
52
+ let empty_meta = || Value::Map(Vec::new());
60
53
  let Some(rest) = text.strip_prefix("---\n") else {
61
- return Ok((Value::Map(Vec::new()), text));
54
+ return Ok((empty_meta(), text));
62
55
  };
63
- let Some(close) = rest.find("\n---") else {
64
- return Err(ModelError::Validation(format!(
65
- "{}: unterminated front matter",
66
- path.display()
67
- )));
56
+
57
+ let mut offset = 0;
58
+ let mut close = None;
59
+ for line in rest.split_inclusive('\n') {
60
+ if line.strip_suffix('\n').unwrap_or(line) == "---" {
61
+ close = Some(offset);
62
+ break;
63
+ }
64
+ offset += line.len();
65
+ }
66
+ let Some(close) = close else {
67
+ return Ok((empty_meta(), text));
68
68
  };
69
- let raw_meta = rest.get(..close).ok_or_else(|| {
70
- ModelError::Validation(format!("{}: unterminated front matter", path.display()))
71
- })?;
72
- let after_meta = rest.get(close..).ok_or_else(|| {
73
- ModelError::Validation(format!("{}: unterminated front matter", path.display()))
74
- })?;
75
- let after_marker = after_meta.strip_prefix("\n---").ok_or_else(|| {
76
- ModelError::Validation(format!("{}: unterminated front matter", path.display()))
77
- })?;
69
+ let raw_meta = &rest[..close];
78
70
  let meta = if raw_meta.trim().is_empty() {
79
- Value::Map(Vec::new())
71
+ empty_meta()
80
72
  } else {
81
- yaml::loads(raw_meta)?
73
+ match yaml::loads(raw_meta) {
74
+ Ok(meta) if meta.is_map() => meta,
75
+ _ => return Ok((empty_meta(), text)),
76
+ }
82
77
  };
83
- if !meta.is_map() {
84
- return Err(ModelError::Validation(format!(
85
- "{}: front matter must be a YAML object",
86
- path.display()
87
- )));
88
- }
78
+ let after_marker = &rest[close + 3..];
89
79
  Ok((meta, after_marker.trim_start_matches('\n').to_string()))
90
80
  }
91
81
 
@@ -161,7 +151,7 @@ pub fn compile_team(team_dir: &Path) -> Result<Value, ModelError> {
161
151
  let workspace = paths::team_workspace(team_dir)?;
162
152
  let workspace_s = workspace.display().to_string();
163
153
  let team_name =
164
- string_field(&team_meta, "name").unwrap_or_else(|| team_dir_parent_name(team_dir));
154
+ string_field(&team_meta, "name").unwrap_or_else(|| workspace_dir_name(&workspace));
165
155
  let objective = string_field(&team_meta, "objective")
166
156
  .or_else(|| non_empty_trimmed(&team_body))
167
157
  .unwrap_or_else(|| "Team Agent document-driven team.".to_string());
@@ -487,9 +477,23 @@ fn compile_role_agent_with_mode(
487
477
  let (meta, body) = read_front_matter(role_path)?;
488
478
  let communication_mode =
489
479
  communication_mode_field(&meta, role_path)?.unwrap_or(team_communication_mode);
490
- let id = required_string(&meta, role_path, "name")?;
491
- let role = required_string(&meta, role_path, "role")?;
492
- let provider = required_string(&meta, role_path, "provider")?;
480
+ let id = string_field(&meta, "agent_id")
481
+ .or_else(|| string_field(&meta, "name"))
482
+ .filter(|id| !id.trim().is_empty())
483
+ .or_else(|| {
484
+ role_path
485
+ .file_stem()
486
+ .and_then(|stem| stem.to_str())
487
+ .filter(|stem| !stem.is_empty())
488
+ .map(ToString::to_string)
489
+ })
490
+ .unwrap_or_else(|| "worker".to_string());
491
+ let role = string_field(&meta, "role")
492
+ .filter(|role| !role.trim().is_empty())
493
+ .unwrap_or_else(|| id.clone());
494
+ let provider = string_field(&meta, "provider")
495
+ .filter(|provider| !provider.trim().is_empty())
496
+ .unwrap_or_else(|| "pi".to_string());
493
497
  require_explicit_grok_role_model(&meta, role_path, &provider)?;
494
498
  require_explicit_cursor_role_model(&meta, role_path, &provider)?;
495
499
  validate_pi_role_fields(&meta, role_path, &provider)?;
@@ -498,7 +502,7 @@ fn compile_role_agent_with_mode(
498
502
  string_field(&meta, "model")
499
503
  .filter(|value| !value.trim().is_empty())
500
504
  .map(Value::Str)
501
- .unwrap_or(Value::Null)
505
+ .unwrap_or_else(|| Value::Str("openai-codex/gpt-6-luna".to_string()))
502
506
  } else {
503
507
  resolve_model(&meta, team_meta, &provider)
504
508
  };
@@ -532,11 +536,9 @@ fn compile_role_agent_with_mode(
532
536
  ("file", Value::Null),
533
537
  ]),
534
538
  ),
535
- // 0.5.66 bypass 单源:compiler 透传角色 md 的 `dangerously_skip_permissions`
536
- // (必填 bool,spec 校验保证存在)。取代旧"恒发 permission_mode: restricted"。
537
539
  (
538
540
  "dangerously_skip_permissions",
539
- Value::Bool(required_dangerously_skip_permissions(&meta, role_path)?),
541
+ Value::Bool(optional_dangerously_skip_permissions(&meta)),
540
542
  ),
541
543
  (
542
544
  "communication_mode",
@@ -575,6 +577,7 @@ fn compile_role_agent_with_mode(
575
577
  })?;
576
578
  Some(parsed)
577
579
  }
580
+ _ if is_pi => Some(ProviderEffort::Max),
578
581
  _ => None,
579
582
  };
580
583
  let team_effort = match string_field(team_meta, "provider_effort") {
@@ -671,15 +674,6 @@ fn string_field(meta: &Value, key: &str) -> Option<String> {
671
674
  .map(ToString::to_string)
672
675
  }
673
676
 
674
- fn required_string(meta: &Value, path: &Path, key: &str) -> Result<String, ModelError> {
675
- string_field(meta, key).ok_or_else(|| {
676
- ModelError::Validation(format!(
677
- "{}: missing front matter field {key}",
678
- path.display()
679
- ))
680
- })
681
- }
682
-
683
677
  /// 缺 model 时框架会填内建默认(grok 上是 grok-4),席位因此静默拿到
684
678
  /// 与角色文件无关的模型和上下文窗口,而 argv 看起来完全正常。
685
679
  /// 角色文件必须自己写死;内建默认、team 级默认、CLI 全局默认都是隐式来源。
@@ -749,19 +743,10 @@ fn validate_pi_role_fields(meta: &Value, path: &Path, provider: &str) -> Result<
749
743
  Ok(())
750
744
  }
751
745
 
752
- /// 0.5.66 bypass 单源:角色 md 的 `dangerously_skip_permissions` 必填 bool。
753
- /// 缺 = 编译失败(消息含硬串);非 bool = 同 fail-loud。
754
- fn required_dangerously_skip_permissions(meta: &Value, path: &Path) -> Result<bool, ModelError> {
746
+ fn optional_dangerously_skip_permissions(meta: &Value) -> bool {
755
747
  match meta.get("dangerously_skip_permissions") {
756
- Some(Value::Bool(value)) => Ok(*value),
757
- Some(_) => Err(ModelError::Validation(format!(
758
- "{}: front matter field dangerously_skip_permissions must be a boolean. This field must be declared explicitly; it controls whether the agent launches with permission prompts bypassed.",
759
- path.display()
760
- ))),
761
- None => Err(ModelError::Validation(format!(
762
- "{}: missing front matter field dangerously_skip_permissions. This field must be declared explicitly; it controls whether the agent launches with permission prompts bypassed.",
763
- path.display()
764
- ))),
748
+ Some(Value::Bool(value)) => *value,
749
+ _ => true,
765
750
  }
766
751
  }
767
752
 
@@ -883,10 +868,9 @@ fn non_empty_trimmed(text: &str) -> Option<String> {
883
868
  }
884
869
  }
885
870
 
886
- fn team_dir_parent_name(team_dir: &Path) -> String {
887
- team_dir
888
- .parent()
889
- .and_then(Path::file_name)
871
+ fn workspace_dir_name(workspace: &Path) -> String {
872
+ workspace
873
+ .file_name()
890
874
  .and_then(|s| s.to_str())
891
875
  .filter(|s| !s.is_empty())
892
876
  .unwrap_or("team")
@@ -105,12 +105,7 @@ fn s3_process_worker() {
105
105
  assert_eq!(line.trim(), "commit");
106
106
  });
107
107
  }
108
- let tools = crate::mcp_server::TeamOrchestratorTools::with_identity(
109
- &dir, Some(crate::model::ids::AgentId::new("leader")), team.map(crate::model::ids::TeamKey::new),
110
- );
111
108
  match action.as_str() {
112
- "assign" => { tools.assign_task(&payload, Some("bounded contract")).unwrap(); }
113
- "note" => { println!("S3 note={:?}", tools.update_state(payload.as_str().unwrap()).unwrap()); }
114
109
  "collect" => {
115
110
  let output = crate::messaging::results::collect_for_team(&dir, Some(&dir.join("result.json")), false, team).unwrap();
116
111
  assert_eq!(output["ok"], true);
@@ -152,10 +147,7 @@ fn s3_tick_preserves_tasks_notes_and_siblings() {
152
147
  seed(dir, false);
153
148
  let hook: SaveHook = Box::new(|ws, observed| {
154
149
  assert_eq!(observed["agents"]["w1"]["worker_state"], "DEAD", "tick must produce a real observation delta");
155
- write(ws.as_path(), "assign", Some("T1"), task("new-task"));
156
- write(ws.as_path(), "note", Some("T1"), json!("concurrent note"));
157
150
  write(ws.as_path(), "collect", Some("T1"), Value::Null);
158
- write(ws.as_path(), "note", Some("T2"), json!("sibling note"));
159
151
  write(ws.as_path(), "sibling", None, Value::Null);
160
152
  let latest = disk(ws.as_path());
161
153
  assert_eq!(latest["teams"]["T1"]["agents"]["w1"]["pane_id"], observed["agents"]["w1"]["pane_id"]);
@@ -166,11 +158,11 @@ fn s3_tick_preserves_tasks_notes_and_siblings() {
166
158
  assert!(report.ok, "tick must save its observation: {report:?}");
167
159
  let state = disk(dir);
168
160
  let team = &state["teams"]["T1"];
161
+ assert_eq!(team["tasks"][0]["id"], "original");
169
162
  assert_eq!(team["tasks"][0]["accepted_result_id"], "result-original");
170
- assert!(team["tasks"].as_array().unwrap().iter().any(|row| row["id"] == "new-task"));
171
- assert_eq!(team["notes"], json!(["original note", "concurrent note"]));
163
+ assert_eq!(team["notes"], json!(["original note"]));
172
164
  assert_eq!(team["agents"]["w1"]["worker_state"], "DEAD");
173
- assert_eq!(state["teams"]["T2"]["notes"], json!(["original note", "sibling note"]));
165
+ assert_eq!(state["teams"]["T2"]["notes"], json!(["original note"]));
174
166
  assert_eq!(state["teams"]["T3"]["notes"], json!(["third"]));
175
167
  assert_eq!(state["tasks"], team["tasks"]);
176
168
  assert_eq!(StateRepository::new(dir).load_team(Some("T1")).unwrap()["tasks"], team["tasks"]);
@@ -179,50 +171,26 @@ fn s3_tick_preserves_tasks_notes_and_siblings() {
179
171
  }
180
172
 
181
173
  #[test]
182
- fn s3_business_writers_commit_once_on_latest() {
183
- isolated("s3_business_writers_commit_once_on_latest", |root| {
184
- for (i, (left, right)) in [
185
- (("assign", task("left")), ("assign", task("right"))),
186
- (("note", json!("same note")), ("note", json!("same note"))),
187
- (("note", json!("same note")), ("assign", task("right"))),
188
- (("collect", Value::Null), ("assign", task("right"))),
189
- (("collect", Value::Null), ("note", json!("same note"))),
190
- ].into_iter().enumerate() {
191
- let dir = root.join(i.to_string());
192
- seed(&dir, false);
193
- let waiting = start_writer(&dir, left.0, Some("T1"), left.1, true);
194
- write(&dir, right.0, Some("T1"), right.1);
195
- finish(waiting, true);
196
- let state = disk(&dir);
197
- let team = &state["teams"]["T1"];
198
- if left.0 == "assign" { assert!(team["tasks"].as_array().unwrap().iter().any(|task| task["id"] == "left")); }
199
- if right.0 == "assign" { assert!(team["tasks"].as_array().unwrap().iter().any(|task| task["id"] == "right")); }
200
- if left.0 == "collect" { assert_eq!(team["tasks"][0]["accepted_result_id"], "result-original"); }
201
- let notes = usize::from(left.0 == "note") + usize::from(right.0 == "note");
202
- assert_eq!(team["notes"].as_array().unwrap().len(), 1 + notes);
203
- if left.0 == "note" && right.0 == "assign" {
204
- let markdown = std::fs::read_to_string(dir.join(".team/runtime/T1/team_state.md")).unwrap();
205
- assert!(markdown.contains("right"), "note must render the committed task view: {markdown}");
206
- }
207
- if left.0 == "collect" {
208
- write(&dir, "assign", Some("T1"), json!({"id": "original", "assignee": "w1", "title": "edited"}));
209
- assert_eq!(disk(&dir)["teams"]["T1"]["tasks"][0]["accepted_result_id"], "result-original");
210
- }
211
- println!("S3 pair {i} canonical={team}");
212
- }
174
+ fn s3_result_collection_commit_preserves_concurrent_sibling() {
175
+ isolated("s3_result_collection_commit_preserves_concurrent_sibling", |dir| {
176
+ seed(dir, false);
177
+ let waiting = start_writer(&dir, "collect", Some("T1"), Value::Null, true);
178
+ write(&dir, "sibling", None, Value::Null);
179
+ finish(waiting, true);
180
+ let state = disk(&dir);
181
+ assert_eq!(state["teams"]["T1"]["tasks"][0]["accepted_result_id"], "result-original");
182
+ assert_eq!(state["teams"]["T3"]["notes"], json!(["third"]));
183
+ assert_eq!(state["teams"]["T2"]["notes"], json!(["original note"]));
213
184
  });
214
185
  }
215
186
 
216
187
  #[test]
217
- fn s3_legacy_note_and_collection_keep_independent_updates() {
218
- isolated("s3_legacy_note_and_collection_keep_independent_updates", |dir| {
188
+ fn s3_legacy_result_collection_preserves_root_shape() {
189
+ isolated("s3_legacy_result_collection_preserves_root_shape", |dir| {
219
190
  seed(dir, true);
220
- let waiting = start_writer(dir, "collect", None, Value::Null, true);
221
- write(dir, "note", None, json!("legacy note"));
222
- finish(waiting, true);
191
+ write(dir, "collect", None, Value::Null);
223
192
  let state = disk(dir);
224
193
  assert_eq!(state["tasks"][0]["accepted_result_id"], "result-original");
225
- assert_eq!(state["notes"], json!(["original note", "legacy note"]));
226
194
  assert!(state.get("teams").is_none(), "legacy single Team shape remains valid");
227
195
  });
228
196
  }
@@ -304,20 +304,7 @@ fn leader_start_plan_with_ambient_authority(
304
304
  identity.team_id.as_str(),
305
305
  );
306
306
  let prompt = crate::lifecycle::worker_command_context::compile_pi_leader_system_prompt();
307
- let team_mcp_tools = [
308
- "assign_task",
309
- "send_message",
310
- "update_state",
311
- "get_team_status",
312
- "stop_agent",
313
- "reset_agent",
314
- "add_agent",
315
- "clone_agent",
316
- "fork_agent",
317
- "request_human",
318
- "stuck_list",
319
- "stuck_cancel",
320
- ];
307
+ let team_mcp_tools = ["send_message", "report_result", "get_team_status"];
321
308
  crate::lifecycle::launch::pi_mcp::materialize_pi_plan(
322
309
  crate::lifecycle::launch::pi_mcp::PiMaterializeRequest {
323
310
  workspace,
@@ -15,14 +15,13 @@
15
15
 
16
16
  use std::collections::BTreeSet;
17
17
  use std::fs::OpenOptions;
18
- use std::io::{Read, Write};
18
+ use std::io::Write;
19
19
  #[cfg(test)]
20
20
  use std::cell::RefCell;
21
21
  use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
22
22
  use std::path::{Path, PathBuf};
23
- use std::process::{Command, Stdio};
24
- use std::sync::mpsc;
25
- use std::time::{Duration, Instant};
23
+ use std::process::Command;
24
+ use std::time::Duration;
26
25
 
27
26
  use sha2::{Digest, Sha256};
28
27
 
@@ -132,62 +131,9 @@ pub(crate) fn new_pi_session_id() -> SessionId {
132
131
  /// errors: 非 UTF-8、header/row 异常、重复或空 catalog 时返回 ProviderError
133
132
  /// ---
134
133
  pub(crate) fn parse_pi_list_models_table(bytes: &[u8]) -> Result<Vec<String>, ProviderError> {
135
- let text = std::str::from_utf8(bytes).map_err(|error| {
136
- ProviderError::Command(format!("Pi model catalog is not UTF-8: {error}"))
137
- })?;
138
- let mut lines = text.lines();
139
- let header = lines
140
- .next()
141
- .ok_or_else(|| ProviderError::Command("Pi model catalog is empty".to_string()))?;
142
- let header_columns = header.split_whitespace().collect::<Vec<_>>();
143
- if header_columns.get(0) != Some(&"provider") || header_columns.get(1) != Some(&"model") {
144
- return Err(ProviderError::Command(
145
- "Pi model catalog has an unexpected header".to_string(),
146
- ));
147
- }
148
- let mut seen = BTreeSet::new();
149
- let mut models = Vec::new();
150
- for (index, line) in lines.enumerate() {
151
- if line.trim().is_empty() {
152
- continue;
153
- }
154
- let malformed = || {
155
- ProviderError::Command(format!(
156
- "Pi model catalog row {} is malformed",
157
- index + 2
158
- ))
159
- };
160
- let (provider, model) = line
161
- .trim()
162
- .split_once(char::is_whitespace)
163
- .ok_or_else(malformed)?;
164
- let mut model = model.trim();
165
- // Pi prints the raw model id, which may contain spaces. Only the
166
- // capability columns following it are single whitespace-free values.
167
- for _ in 2..header_columns.len() {
168
- model = model
169
- .rsplit_once(char::is_whitespace)
170
- .ok_or_else(malformed)?
171
- .0
172
- .trim_end();
173
- }
174
- if model.is_empty() {
175
- return Err(malformed());
176
- }
177
- let exact = format!("{provider}/{model}");
178
- if !seen.insert(exact.clone()) {
179
- return Err(ProviderError::Command(format!(
180
- "Pi model catalog contains duplicate exact id {exact:?}"
181
- )));
182
- }
183
- models.push(exact);
184
- }
185
- if models.is_empty() {
186
- return Err(ProviderError::Command(
187
- "Pi model catalog contains no models".to_string(),
188
- ));
189
- }
190
- Ok(models)
134
+ crate::provider::model_catalog::parse_pi_catalog(bytes)
135
+ .map(|records| records.into_iter().map(|record| record.id).collect())
136
+ .map_err(|error| ProviderError::Command(error.pi_legacy_message()))
191
137
  }
192
138
 
193
139
  /// ---
@@ -658,43 +604,6 @@ fn observe_pi_catalog_test(f: impl FnOnce(&mut PiCatalogTestContext)) {
658
604
  });
659
605
  }
660
606
 
661
- fn spawn_catalog_reader(
662
- stdout: std::process::ChildStdout,
663
- max_bytes: u64,
664
- ) -> mpsc::Receiver<(std::io::Result<usize>, Vec<u8>)> {
665
- let (sender, receiver) = mpsc::channel();
666
- std::thread::spawn(move || {
667
- let mut bytes = Vec::new();
668
- let result = stdout.take(max_bytes + 1).read_to_end(&mut bytes);
669
- let _ = sender.send((result, bytes));
670
- });
671
- receiver
672
- }
673
-
674
- fn finish_catalog_reader(
675
- receiver: mpsc::Receiver<(std::io::Result<usize>, Vec<u8>)>,
676
- status: std::process::ExitStatus,
677
- timeout: Duration,
678
- max_bytes: u64,
679
- ) -> Result<Vec<u8>, String> {
680
- let (result, bytes) = match receiver.recv_timeout(timeout) {
681
- Ok(value) => value,
682
- Err(_) => {
683
- #[cfg(test)]
684
- observe_pi_catalog_test(|context| context.observation.reader_timeout = true);
685
- return Err("Pi model catalog command timed out".to_string());
686
- }
687
- };
688
- if !status.success() {
689
- return Err("Pi model catalog command failed".into());
690
- }
691
- result.map_err(|_| "Pi model catalog could not be read".to_string())?;
692
- if bytes.len() as u64 > max_bytes {
693
- return Err("Pi model catalog exceeds the bounded output limit".into());
694
- }
695
- Ok(bytes)
696
- }
697
-
698
607
  #[cfg(test)]
699
608
  pub(crate) fn run_pi_catalog_after_parent_exit_for_test(
700
609
  stdout: std::process::ChildStdout,
@@ -708,86 +617,53 @@ pub(crate) fn run_pi_catalog_after_parent_exit_for_test(
708
617
  context.observation.parent_exit_success = Some(status.success());
709
618
  context.observation.parent_exit_code = status.code();
710
619
  });
711
- finish_catalog_reader(
712
- spawn_catalog_reader(stdout, max_bytes),
713
- status,
714
- timeout,
715
- max_bytes,
620
+ crate::provider::model_catalog::finish_stdout_after_exit(
621
+ stdout, status, timeout, max_bytes, &mut PiCatalogObserver,
716
622
  )
623
+ .map_err(|error| error.pi_legacy_message())
717
624
  }
718
625
 
719
- /// Discover candidates with one PATH-first catalog observation. This deliberately
720
- /// does not resolve adapters or scan wrapper chains; callers use it only for
721
- /// rejecting an unqualified role model before lifecycle mutation.
626
+ /// Shared bounded native-command runner; launch preflight keeps its exact model gate.
722
627
  pub(crate) fn run_pi_catalog(
723
628
  executable: &Path,
724
629
  timeout: Duration,
725
630
  max_bytes: u64,
726
631
  ) -> Result<Vec<u8>, String> {
727
- let mut command = Command::new(executable);
728
- command.arg("--list-models");
632
+ let mut extra_env = Vec::new();
729
633
  #[cfg(test)]
730
634
  observe_pi_catalog_test(|context| {
731
635
  context.observation.spawn_count += 1;
732
636
  context.observation.argv.push("--list-models".to_string());
733
- command.env("TEAM_AGENT_MODELS_TIMEOUT_RECEIPT", &context.receipt);
637
+ extra_env.push(("TEAM_AGENT_MODELS_TIMEOUT_RECEIPT".into(), context.receipt.as_os_str().into()));
734
638
  if let Some(mode) = context.helper_mode.as_deref() {
735
- command.env("TEAM_AGENT_MODELS_TIMEOUT_MODE", mode);
639
+ extra_env.push(("TEAM_AGENT_MODELS_TIMEOUT_MODE".into(), mode.into()));
736
640
  }
737
641
  });
738
- let mut child = command
739
- .stdin(Stdio::null())
740
- .stdout(Stdio::piped())
741
- .stderr(Stdio::null())
742
- .spawn()
743
- .map_err(|error| {
744
- #[cfg(test)]
745
- {
746
- eprintln!(
747
- "run_pi_catalog spawn failed kind={:?} raw_os_error={:?}",
748
- error.kind(),
749
- error.raw_os_error()
750
- );
751
- }
752
- let _ = error;
753
- "Pi executable is unavailable on PATH".to_string()
754
- })?;
642
+ crate::provider::model_catalog::run_command(
643
+ executable, &["--list-models"], None, None, &[], &extra_env, timeout, max_bytes,
644
+ &mut PiCatalogObserver,
645
+ )
646
+ .map_err(|error| error.pi_legacy_message())
647
+ }
648
+
649
+ struct PiCatalogObserver;
650
+ impl crate::provider::model_catalog::CatalogObserver for PiCatalogObserver {
755
651
  #[cfg(test)]
756
- observe_pi_catalog_test(|context| context.observation.parent_pid = Some(child.id()));
757
- let stdout = child.stdout.take().ok_or_else(|| {
758
- let _ = child.kill();
759
- let _ = child.wait();
760
- "Pi model catalog output unavailable".to_string()
761
- })?;
762
- let receiver = spawn_catalog_reader(stdout, max_bytes);
763
- let start = Instant::now();
764
- loop {
765
- match child.try_wait() {
766
- Ok(Some(status)) => {
767
- #[cfg(test)]
768
- observe_pi_catalog_test(|context| {
769
- context.observation.parent_exit_success = Some(status.success());
770
- context.observation.parent_exit_code = status.code();
771
- });
772
- return finish_catalog_reader(
773
- receiver,
774
- status,
775
- timeout.saturating_sub(start.elapsed()),
776
- max_bytes,
777
- );
778
- }
779
- Ok(None) if start.elapsed() < timeout => std::thread::sleep(Duration::from_millis(20)),
780
- Ok(None) => {
781
- let _ = child.kill();
782
- let _ = child.wait();
783
- return Err("Pi model catalog command timed out".into());
784
- }
785
- Err(_) => {
786
- let _ = child.kill();
787
- let _ = child.wait();
788
- return Err("Pi model catalog command could not be observed".into());
789
- }
790
- }
652
+ fn spawned(&mut self, pid: u32) {
653
+ observe_pi_catalog_test(|context| context.observation.parent_pid = Some(pid));
654
+ }
655
+
656
+ #[cfg(test)]
657
+ fn exited(&mut self, status: &std::process::ExitStatus) {
658
+ observe_pi_catalog_test(|context| {
659
+ context.observation.parent_exit_success = Some(status.success());
660
+ context.observation.parent_exit_code = status.code();
661
+ });
662
+ }
663
+
664
+ #[cfg(test)]
665
+ fn output_timeout(&mut self) {
666
+ observe_pi_catalog_test(|context| context.observation.reader_timeout = true);
791
667
  }
792
668
  }
793
669
 
@@ -167,7 +167,7 @@ pub(super) fn spawn_agents(
167
167
  model: command_model,
168
168
  effort: agent_effort,
169
169
  system_prompt: &system_prompt,
170
- team_mcp_tools: &["send_message", "report_result"],
170
+ team_mcp_tools: &["send_message", "report_result", "get_team_status"],
171
171
  mcp_config: &mcp_config,
172
172
  session_scope: crate::lifecycle::launch::pi_mcp::PiSessionScope::Isolated,
173
173
  },
@@ -397,7 +397,7 @@ pub(super) fn spawn_agent_window(
397
397
  model: command_model,
398
398
  effort: restart_effort,
399
399
  system_prompt: &system_prompt,
400
- team_mcp_tools: &["send_message", "report_result"],
400
+ team_mcp_tools: &["send_message", "report_result", "get_team_status"],
401
401
  mcp_config: &mcp_config,
402
402
  session_scope: crate::lifecycle::launch::pi_mcp::PiSessionScope::Isolated,
403
403
  };
@@ -532,22 +532,6 @@ fn reset_stopped_true_still_rechecks_same_role_residue_before_start() {
532
532
  );
533
533
  }
534
534
 
535
- #[test]
536
- fn mcp_reset_output_exposes_capture_outcome_and_weak_reset_proof() {
537
- let src = include_str!(concat!(
538
- env!("CARGO_MANIFEST_DIR"),
539
- "/src/mcp_server/lifecycle_tools/agent_ops.rs"
540
- ));
541
- let reset = source_section(src, "pub(crate) fn reset_agent", "pub(crate) fn fork_agent");
542
- let json_object = reset_success_json_object(reset);
543
- let missing = ["\"capture_state\":", "\"reset_proof\":", "\"transcript_missing\"", "\"attribution_ambiguous\"", "\"weak\""]
544
- .into_iter().filter(|needle| !json_object.contains(needle)).collect::<Vec<_>>();
545
- assert!(
546
- missing.is_empty(),
547
- "P0 MCP reset: ok=true/status=reset must expose additive capture_state and reset_proof=weak inside the ResetAgentOutcome::Reset serde_json::json! object; missing={missing:?}; reset_json={json_object}"
548
- );
549
- }
550
-
551
535
  #[test]
552
536
  #[serial(env)]
553
537
  fn abnormal_shell_pane_with_live_delivery_is_not_dead_and_dedupe_ignores_size() {
@@ -972,15 +956,3 @@ impl ProviderRegistry for RealAdapterRegistry {
972
956
  get_adapter(provider)
973
957
  }
974
958
  }
975
-
976
- fn source_section<'a>(src: &'a str, start: &str, end: &str) -> &'a str { let start = src.find(start).unwrap_or(0); let rest = &src[start..]; &rest[..rest.find(end).unwrap_or(rest.len())] }
977
-
978
- fn reset_success_json_object(reset_src: &str) -> &str {
979
- let success = source_section(reset_src, "ResetAgentOutcome::Reset", "ResetAgentOutcome::Refused");
980
- let macro_start = success.find("serde_json::json!({").expect("reset success json object"); let object_start = macro_start + success[macro_start..].find('{').unwrap(); let mut depth = 0_i32;
981
- for (offset, ch) in success[object_start..].char_indices() {
982
- if ch == '{' { depth += 1; } if ch == '}' { depth -= 1; }
983
- if depth == 0 { return &success[object_start..object_start + offset + 1]; }
984
- }
985
- panic!("unterminated reset success json object")
986
- }
@@ -108,12 +108,13 @@ fn pi_role_ignores_tools_and_preserves_provider_defaults() {
108
108
  .expect("Pi model and effort may use provider defaults");
109
109
  assert_eq!(
110
110
  compiled.agent.get("model"),
111
- Some(&Value::Null),
112
- "omitted Pi model must not inherit a synthesized framework default"
111
+ Some(&Value::Str("openai-codex/gpt-6-luna".to_string())),
112
+ "omitted Pi model uses the Luna default rather than team metadata"
113
113
  );
114
- assert!(
115
- compiled.agent.get("effort").is_none(),
116
- "omitted Pi effort must not inherit a synthesized framework default"
114
+ assert_eq!(
115
+ compiled.agent.get("effort"),
116
+ Some(&Value::Str("max".to_string())),
117
+ "omitted Pi effort uses the global max default rather than team metadata"
117
118
  );
118
119
 
119
120
  let models = parse_pi_list_models_table(CATALOG).expect("catalog fixture");
@@ -160,10 +161,11 @@ fn pi_role_uses_standard_bypass_field_without_fabricating_argv() {
160
161
  );
161
162
 
162
163
  let missing_ack = valid_role_with("").replace("dangerously_skip_permissions: true\n", "");
163
- let text = compile_error(compile_pi_role(&missing_ack)).to_string();
164
- assert!(
165
- text.contains("missing front matter field dangerously_skip_permissions"),
166
- "got {text}"
164
+ let compiled = compile_pi_role(&missing_ack)
165
+ .expect("omitted bypass metadata defaults to true");
166
+ assert_eq!(
167
+ compiled.agent.get("dangerously_skip_permissions"),
168
+ Some(&Value::Bool(true))
167
169
  );
168
170
  }
169
171