@team-agent/installer 0.5.113 → 0.5.115

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 (52) hide show
  1. package/Cargo.lock +252 -84
  2. package/Cargo.toml +3 -1
  3. package/crates/team-agent/Cargo.toml +3 -0
  4. package/crates/team-agent/src/cli/adapters.rs +28 -15
  5. package/crates/team-agent/src/cli/diagnose.rs +36 -13
  6. package/crates/team-agent/src/cli/emit.rs +57 -22
  7. package/crates/team-agent/src/cli/leader.rs +16 -0
  8. package/crates/team-agent/src/cli/leader_prompt.rs +261 -0
  9. package/crates/team-agent/src/cli/mod.rs +157 -8
  10. package/crates/team-agent/src/cli/models.rs +119 -18
  11. package/crates/team-agent/src/cli/spec.rs +7 -1
  12. package/crates/team-agent/src/cli/status_port/brief.rs +268 -101
  13. package/crates/team-agent/src/cli/tests/base.rs +88 -0
  14. package/crates/team-agent/src/cli/tests/status_send.rs +2 -0
  15. package/crates/team-agent/src/compiler.rs +66 -18
  16. package/crates/team-agent/src/contract_runtime/backend.rs +959 -0
  17. package/crates/team-agent/src/contract_runtime/commands.rs +245 -0
  18. package/crates/team-agent/src/contract_runtime/config.rs +340 -0
  19. package/crates/team-agent/src/contract_runtime/config_tests.rs +56 -0
  20. package/crates/team-agent/src/contract_runtime/discovery.rs +383 -0
  21. package/crates/team-agent/src/contract_runtime/forward.rs +229 -0
  22. package/crates/team-agent/src/contract_runtime/framework.rs +294 -0
  23. package/crates/team-agent/src/contract_runtime/mcp.rs +396 -0
  24. package/crates/team-agent/src/contract_runtime/mod.rs +16 -0
  25. package/crates/team-agent/src/contract_runtime/prompt.rs +216 -0
  26. package/crates/team-agent/src/contract_runtime/pump.rs +269 -0
  27. package/crates/team-agent/src/contract_runtime/registry.rs +197 -0
  28. package/crates/team-agent/src/contract_runtime/workspace.rs +160 -0
  29. package/crates/team-agent/src/coordinator/tick.rs +26 -8
  30. package/crates/team-agent/src/leader/mod.rs +1 -0
  31. package/crates/team-agent/src/leader/prompt.rs +906 -0
  32. package/crates/team-agent/src/leader/start.rs +353 -21
  33. package/crates/team-agent/src/leader/tests/mod.rs +2 -0
  34. package/crates/team-agent/src/leader/tests/prompt_contract.rs +268 -0
  35. package/crates/team-agent/src/leader/types.rs +6 -0
  36. package/crates/team-agent/src/lib.rs +4 -0
  37. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +571 -389
  38. package/crates/team-agent/src/lifecycle/launch/spawn.rs +38 -9
  39. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +15 -0
  40. package/crates/team-agent/src/lifecycle/launch.rs +45 -28
  41. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +23 -1
  42. package/crates/team-agent/src/lifecycle/restart/selection.rs +34 -0
  43. package/crates/team-agent/src/lifecycle/role_config.rs +17 -1
  44. package/crates/team-agent/src/lifecycle/types.rs +4 -1
  45. package/crates/team-agent/src/lifecycle/worker_command_context.rs +37 -27
  46. package/crates/team-agent/src/main.rs +6 -0
  47. package/crates/team-agent/src/messaging/delivery.rs +50 -1
  48. package/crates/team-agent/src/messaging/send.rs +10 -0
  49. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +131 -0
  50. package/crates/team-agent/src/model/spec.rs +24 -12
  51. package/crates/team-agent/src/state/persist.rs +73 -0
  52. package/package.json +4 -4
@@ -0,0 +1,261 @@
1
+ //! Early, host-global command dispatch: never select or initialize a workspace.
2
+ use super::ExitCode;
3
+ use crate::leader::prompt::{self, Mutation, PromptError};
4
+ use serde_json::json;
5
+ use std::io::{self, Write};
6
+ use std::path::PathBuf;
7
+
8
+ pub(crate) const HELP: &str = "Purpose:\nPersist supplemental system instructions for future Team Agent Leader launches only.\nUsage: team-agent leader-prompt [show|set|append|clear] [--json] [--file PATH | -- TEXT]\n\nOptions:\nshow (default) prints the exact text; set replaces it; append joins old and new text with two newlines; clear removes it.\nset/append require exactly one TEXT token after --, or --file PATH containing UTF-8 text. No implicit stdin.\nEverything after -- is literal text, including --json and --help. Empty text and NUL are rejected.\nConfiguration: ~/.team-agent/leader-prompt.txt, independent of the workspace. Mutations do not echo the text.\nOnly new Pi, Claude, Codex and Grok Leaders apply it; Worker launches never read it. Cursor/Copilot are refused when configured.\nCodex merges explicit argv developer_instructions, but overrides that user/profile configuration slot; migrate its existing text here.\nNative core prompts and unrelated launch arguments remain unchanged. Attach does not update an existing session.\nExamples:\nteam-agent leader-prompt show\nteam-agent leader-prompt set -- 'Keep replies concise.'\nteam-agent leader-prompt clear\n\nNext Action:\nUse show to inspect the text, then start a new Leader with team-agent pi (or claude/codex/grok).\nset/append/clear affect the next new process, not running Leaders or their conversation history.";
9
+
10
+ #[derive(Clone, Copy, Debug, Eq, PartialEq)]
11
+ enum Operation {
12
+ Show,
13
+ Set,
14
+ Append,
15
+ Clear,
16
+ }
17
+ impl Operation {
18
+ fn name(self) -> &'static str {
19
+ match self {
20
+ Self::Show => "show",
21
+ Self::Set => "set",
22
+ Self::Append => "append",
23
+ Self::Clear => "clear",
24
+ }
25
+ }
26
+ }
27
+ #[derive(Debug, Eq, PartialEq)]
28
+ enum Input {
29
+ Text(String),
30
+ File(PathBuf),
31
+ }
32
+ #[derive(Debug)]
33
+ struct Request {
34
+ operation: Operation,
35
+ input: Option<Input>,
36
+ json: bool,
37
+ help: bool,
38
+ }
39
+ fn usage() -> PromptError {
40
+ PromptError::new(
41
+ "leader_prompt_invalid_input",
42
+ "Invalid or incomplete leader-prompt arguments; use one input for set/append",
43
+ None,
44
+ )
45
+ }
46
+ fn parse(args: &[String]) -> Result<Request, PromptError> {
47
+ let end = args
48
+ .iter()
49
+ .position(|arg| arg == "--")
50
+ .unwrap_or(args.len());
51
+ let mut json = false;
52
+ let mut help = false;
53
+ let mut file = None;
54
+ let mut positional = None;
55
+ let mut i = 0;
56
+ while i < end {
57
+ match args[i].as_str() {
58
+ "--json" if !json => json = true,
59
+ "--help" | "-h" if !help => help = true,
60
+ "--stdin" => {
61
+ return Err(PromptError::new(
62
+ "leader_prompt_invalid_input",
63
+ "--stdin is not supported for leader-prompt; use -- TEXT or --file PATH instead",
64
+ None,
65
+ ));
66
+ }
67
+ "--file" if file.is_none() && i + 1 < end => {
68
+ i += 1;
69
+ if matches!(args[i].as_str(), "--json" | "--help" | "-h" | "--file") {
70
+ return Err(usage());
71
+ }
72
+ file = Some(PathBuf::from(&args[i]));
73
+ }
74
+ token if !token.starts_with('-') && positional.is_none() => positional = Some(token),
75
+ _ => return Err(usage()),
76
+ }
77
+ i += 1;
78
+ }
79
+ let operation = match positional.unwrap_or("show") {
80
+ "show" => Operation::Show,
81
+ "set" => Operation::Set,
82
+ "append" => Operation::Append,
83
+ "clear" => Operation::Clear,
84
+ _ => return Err(usage()),
85
+ };
86
+ let text = if end < args.len() {
87
+ if args.len() != end + 2 {
88
+ return Err(usage());
89
+ }
90
+ Some(args[end + 1].clone())
91
+ } else {
92
+ None
93
+ };
94
+ if (file.is_some() && text.is_some()) || (help && (file.is_some() || text.is_some())) {
95
+ return Err(usage());
96
+ }
97
+ let input = file.map(Input::File).or_else(|| text.map(Input::Text));
98
+ if !help && (matches!(operation, Operation::Set | Operation::Append) != input.is_some()) {
99
+ return Err(usage());
100
+ }
101
+ Ok(Request {
102
+ operation,
103
+ input,
104
+ json,
105
+ help,
106
+ })
107
+ }
108
+ fn execute(request: Request) -> Result<(), PromptError> {
109
+ let path = prompt::config_path()?;
110
+ if request.operation == Operation::Show {
111
+ let text = prompt::read(&path)?.unwrap_or_default();
112
+ if request.json {
113
+ println!(
114
+ "{}",
115
+ json!({"configured": !text.is_empty(), "config_path": path, "prompt": text})
116
+ );
117
+ } else {
118
+ // Unlike mutation diagnostics, explicit show must preserve every byte.
119
+ io::stdout()
120
+ .write_all(text.as_bytes())
121
+ .and_then(|_| io::stdout().flush())
122
+ .map_err(|error| PromptError::io("leader_prompt_write_failed", &path, error))?;
123
+ }
124
+ return Ok(());
125
+ }
126
+ let mutation = match request.operation {
127
+ Operation::Clear => Mutation::Clear,
128
+ _ => {
129
+ let text = match request.input {
130
+ Some(Input::Text(text)) => text,
131
+ Some(Input::File(input)) => std::fs::read_to_string(&input).map_err(|error| {
132
+ PromptError::io("leader_prompt_unreadable", &input, error).at_config(&path)
133
+ })?,
134
+ None => return Err(usage()),
135
+ };
136
+ if request.operation == Operation::Set {
137
+ Mutation::Set(text)
138
+ } else {
139
+ Mutation::Append(text)
140
+ }
141
+ }
142
+ };
143
+ let configured = prompt::mutate(&path, mutation)?;
144
+ if request.json {
145
+ println!(
146
+ "{}",
147
+ json!({"operation": request.operation.name(), "configured": configured, "config_path": path})
148
+ );
149
+ } else {
150
+ println!(
151
+ "Leader prompt {}: configured={configured}; config={}",
152
+ request.operation.name(),
153
+ path.display()
154
+ );
155
+ }
156
+ Ok(())
157
+ }
158
+ pub(crate) fn run(args: &[String]) -> ExitCode {
159
+ let end = args
160
+ .iter()
161
+ .position(|arg| arg == "--")
162
+ .unwrap_or(args.len());
163
+ let as_json = args[..end].iter().any(|arg| arg == "--json");
164
+ let result = match parse(args) {
165
+ Ok(request) if request.help => {
166
+ println!("{HELP}");
167
+ return ExitCode::Ok;
168
+ }
169
+ Ok(request) => execute(request),
170
+ Err(error) => Err(error),
171
+ };
172
+ match result {
173
+ Ok(()) => ExitCode::Ok,
174
+ Err(error) => {
175
+ if as_json {
176
+ println!("{}", error.report());
177
+ } else {
178
+ eprintln!("{error}");
179
+ }
180
+ if error.reason == "leader_prompt_invalid_input" {
181
+ ExitCode::Usage
182
+ } else {
183
+ ExitCode::Error
184
+ }
185
+ }
186
+ }
187
+ }
188
+
189
+ #[cfg(test)]
190
+ #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
191
+ mod tests {
192
+ use super::*;
193
+ fn args(tokens: &[&str]) -> Vec<String> {
194
+ tokens.iter().map(|text| text.to_string()).collect()
195
+ }
196
+ #[test]
197
+ fn leader_prompt_controls_stop_at_delimiter() {
198
+ for text in ["--json", "--help", "--file", "--stdin", "multi\nline🙂", ""] {
199
+ let request = parse(&args(&["set", "--", text])).unwrap();
200
+ assert!(!request.json && !request.help);
201
+ assert_eq!(request.input, Some(Input::Text(text.into())));
202
+ }
203
+ assert!(parse(&args(&[])).unwrap().operation == Operation::Show);
204
+ let request = parse(&args(&["--json", "append", "--file", "path with 中文"])).unwrap();
205
+ assert_eq!(
206
+ request.input,
207
+ Some(Input::File(PathBuf::from("path with 中文")))
208
+ );
209
+ assert!(request.json);
210
+ }
211
+ #[test]
212
+ fn leader_prompt_stdin_control_reports_unsupported_input_with_usage_exit() {
213
+ for tokens in [
214
+ vec!["set", "--stdin"],
215
+ vec!["set", "--json", "--stdin"],
216
+ vec!["append", "--json", "--stdin"],
217
+ ] {
218
+ let tokens = args(&tokens);
219
+ let error = parse(&tokens).unwrap_err();
220
+ assert_eq!(error.reason, "leader_prompt_invalid_input");
221
+ assert_eq!(
222
+ error.error,
223
+ "--stdin is not supported for leader-prompt; use -- TEXT or --file PATH instead"
224
+ );
225
+ assert!(error.config_path.is_none());
226
+ assert_eq!(error.report()["reason"], "leader_prompt_invalid_input");
227
+ assert_eq!(error.report()["error"], error.error);
228
+ assert!(error.to_string().contains("--stdin is not supported"));
229
+ assert_eq!(run(&tokens).code(), 2);
230
+ }
231
+ }
232
+ #[test]
233
+ fn leader_prompt_stdin_file_value_is_not_reclassified_as_a_control() {
234
+ for operation in ["set", "append"] {
235
+ let request = parse(&args(&[operation, "--file", "--stdin"])).unwrap();
236
+ assert_eq!(request.input, Some(Input::File(PathBuf::from("--stdin"))));
237
+ }
238
+ }
239
+ #[test]
240
+ fn leader_prompt_rejects_duplicate_missing_unknown_or_extra_controls() {
241
+ for tokens in [
242
+ vec!["set"],
243
+ vec!["append", "--"],
244
+ vec!["set", "--", "one", "two"],
245
+ vec!["set", "--file"],
246
+ vec!["set", "--file", "input", "--", "text"],
247
+ vec!["show", "--", "text"],
248
+ vec!["clear", "--file", "input"],
249
+ vec!["show", "--json", "--json"],
250
+ vec!["set", "--file", "one", "--file", "two"],
251
+ vec!["show", "--unknown"],
252
+ vec!["show", "clear"],
253
+ vec!["unknown", "--help"],
254
+ ] {
255
+ assert_eq!(
256
+ parse(&args(&tokens)).unwrap_err().reason,
257
+ "leader_prompt_invalid_input"
258
+ );
259
+ }
260
+ }
261
+ }
@@ -66,6 +66,7 @@ pub mod leaders;
66
66
  pub mod models;
67
67
  pub mod named_address;
68
68
  pub mod profile;
69
+ pub(crate) mod leader_prompt;
69
70
  pub(crate) mod route;
70
71
  pub mod send;
71
72
  pub(crate) mod spec;
@@ -172,12 +173,18 @@ pub mod lifecycle_port {
172
173
  Err(crate::leader::start::PrepareLeaderStartError::PaneAuthorityRefused(refusal)) => {
173
174
  return Ok(pane_authority_refusal_value(provider, attach, &refusal));
174
175
  }
176
+ Err(crate::leader::start::PrepareLeaderStartError::Leader(
177
+ crate::leader::LeaderError::Prompt(error),
178
+ )) => return Ok(error.report()),
175
179
  Err(crate::leader::start::PrepareLeaderStartError::Leader(error)) => {
176
180
  return Err(CliError::Runtime(error.to_string()));
177
181
  }
178
182
  };
179
- let outcome = crate::leader::start::execute_prepared_leader_start(&prepared, cwd)
180
- .map_err(|e| CliError::Runtime(e.to_string()))?;
183
+ let outcome = match crate::leader::start::execute_prepared_leader_start(&prepared, cwd) {
184
+ Ok(outcome) => outcome,
185
+ Err(crate::leader::LeaderError::Prompt(error)) => return Ok(error.report()),
186
+ Err(error) => return Err(CliError::Runtime(error.to_string())),
187
+ };
181
188
  let plan = prepared.plan();
182
189
  let ok = match outcome.status {
183
190
  crate::leader::LeaderLaunchStatus::Exited => outcome.exit_code == Some(0),
@@ -1297,6 +1304,16 @@ pub mod lifecycle_port {
1297
1304
  Some(state) => state,
1298
1305
  None => shutdown_state_for_team(&run_workspace, team)?,
1299
1306
  };
1307
+ #[cfg(unix)]
1308
+ if crate::contract_runtime::framework::has_contract(&state) {
1309
+ crate::contract_runtime::framework::stop_selected(&run_workspace, &state).map_err(
1310
+ |error| {
1311
+ CliError::Runtime(format!(
1312
+ "native shutdown failed; owned resources preserved: {error}"
1313
+ ))
1314
+ },
1315
+ )?;
1316
+ }
1300
1317
  deadline.check("refresh_provider_sessions")?;
1301
1318
  let captured_missing_sessions =
1302
1319
  crate::lifecycle::restart::refresh_missing_provider_sessions(&mut state)
@@ -3108,6 +3125,10 @@ pub mod lifecycle_port {
3108
3125
  team: Option<&str>,
3109
3126
  session_converge_deadline_ms: Option<u64>,
3110
3127
  ) -> Result<Value, CliError> {
3128
+ #[cfg(unix)]
3129
+ if let Some(receipt) = crate::contract_runtime::commands::restart(workspace, team)? {
3130
+ return Ok(receipt);
3131
+ }
3111
3132
  match crate::lifecycle::restart_with_session_convergence_deadline(
3112
3133
  workspace,
3113
3134
  allow_fresh,
@@ -3128,6 +3149,23 @@ pub mod lifecycle_port {
3128
3149
  team: Option<&str>,
3129
3150
  role_config: &crate::lifecycle::role_config::RoleConfigPatch,
3130
3151
  ) -> Result<Value, CliError> {
3152
+ #[cfg(unix)]
3153
+ if let Some(receipt) = crate::contract_runtime::commands::start_agent(workspace, team, agent, role_config)? {
3154
+ return Ok(receipt);
3155
+ }
3156
+ #[cfg(unix)]
3157
+ {
3158
+ crate::contract_runtime::framework::require_legacy_lifecycle(
3159
+ workspace,
3160
+ team,
3161
+ Some(agent),
3162
+ "start-agent",
3163
+ )?;
3164
+ crate::contract_runtime::framework::require_legacy_role(
3165
+ None,
3166
+ role_config.provider.as_deref(),
3167
+ )?;
3168
+ }
3131
3169
  let _ = force; // Never grants authority to stop an already-running seat.
3132
3170
  let agent_id = crate::model::ids::AgentId::new(agent);
3133
3171
  match crate::lifecycle::role_config::start_agent_from_role(
@@ -3194,7 +3232,15 @@ pub mod lifecycle_port {
3194
3232
  agent: &str,
3195
3233
  team: Option<&str>,
3196
3234
  ) -> Result<Value, CliError> {
3235
+ #[cfg(unix)]
3236
+ if let Some(receipt) = crate::contract_runtime::commands::stop_agent(workspace, team, agent)? {
3237
+ return Ok(receipt);
3238
+ }
3197
3239
  let agent_id = crate::model::ids::AgentId::new(agent);
3240
+ #[cfg(unix)]
3241
+ crate::contract_runtime::framework::require_legacy_lifecycle(
3242
+ workspace, team, Some(agent), "stop-agent",
3243
+ )?;
3198
3244
  match crate::lifecycle::stop_agent(workspace, &agent_id, team) {
3199
3245
  Ok(report) => Ok(json!({"ok": true, "agent_id": agent, "stopped": report.stopped})),
3200
3246
  Err(e) => Ok(error_value(e)),
@@ -3209,6 +3255,13 @@ pub mod lifecycle_port {
3209
3255
  team: Option<&str>,
3210
3256
  ) -> Result<Value, CliError> {
3211
3257
  let agent_id = crate::model::ids::AgentId::new(agent);
3258
+ #[cfg(unix)]
3259
+ crate::contract_runtime::framework::require_legacy_lifecycle(
3260
+ workspace,
3261
+ team,
3262
+ Some(agent),
3263
+ "reset-agent",
3264
+ )?;
3212
3265
  match crate::lifecycle::reset_agent(workspace, &agent_id, discard_session, true, team) {
3213
3266
  Ok(crate::lifecycle::ResetAgentOutcome::Reset {
3214
3267
  env,
@@ -3256,6 +3309,19 @@ pub mod lifecycle_port {
3256
3309
  ) -> Result<Value, CliError> {
3257
3310
  let agent_id = crate::model::ids::AgentId::new(agent);
3258
3311
  let source = (!role_file.is_empty()).then(|| Path::new(role_file));
3312
+ #[cfg(unix)]
3313
+ {
3314
+ crate::contract_runtime::framework::require_legacy_lifecycle(
3315
+ workspace,
3316
+ team,
3317
+ Some(agent),
3318
+ "add-agent",
3319
+ )?;
3320
+ crate::contract_runtime::framework::require_legacy_role(
3321
+ source,
3322
+ role_config.provider.as_deref(),
3323
+ )?;
3324
+ }
3259
3325
  match crate::lifecycle::role_config::add_agent_from_role(
3260
3326
  workspace,
3261
3327
  &agent_id,
@@ -3308,6 +3374,13 @@ pub mod lifecycle_port {
3308
3374
  ) -> Result<Value, CliError> {
3309
3375
  let source = crate::model::ids::AgentId::new(source_agent);
3310
3376
  let dest = crate::model::ids::AgentId::new(as_agent_id);
3377
+ #[cfg(unix)]
3378
+ crate::contract_runtime::framework::require_legacy_lifecycle(
3379
+ workspace,
3380
+ team,
3381
+ Some(source_agent),
3382
+ "fork-agent",
3383
+ )?;
3311
3384
  match crate::lifecycle::fork_agent(workspace, &source, &dest, label, true, team) {
3312
3385
  Ok(report) => {
3313
3386
  let mut value = json!({
@@ -3345,6 +3418,13 @@ pub mod lifecycle_port {
3345
3418
  ) -> Result<Value, CliError> {
3346
3419
  let source = crate::model::ids::AgentId::new(source_agent);
3347
3420
  let dest = crate::model::ids::AgentId::new(as_agent_id);
3421
+ #[cfg(unix)]
3422
+ crate::contract_runtime::framework::require_legacy_lifecycle(
3423
+ workspace,
3424
+ team,
3425
+ Some(source_agent),
3426
+ "clone-agent",
3427
+ )?;
3348
3428
  match crate::lifecycle::clone_agent(workspace, &source, &dest, label, true, team) {
3349
3429
  Ok(report) => Ok(json!({
3350
3430
  "ok": true,
@@ -3370,6 +3450,13 @@ pub mod lifecycle_port {
3370
3450
  team: Option<&str>,
3371
3451
  ) -> Result<Value, CliError> {
3372
3452
  let agent_id = crate::model::ids::AgentId::new(agent);
3453
+ #[cfg(unix)]
3454
+ crate::contract_runtime::framework::require_legacy_lifecycle(
3455
+ workspace,
3456
+ team,
3457
+ Some(agent),
3458
+ "remove-agent",
3459
+ )?;
3373
3460
  match crate::lifecycle::remove_agent_flag_requirements(workspace, &agent_id, team) {
3374
3461
  Ok(requirements) => {
3375
3462
  if !remove_agent_missing_flags(from_spec, confirm, force, &requirements).is_empty()
@@ -3712,7 +3799,13 @@ pub mod lifecycle_port {
3712
3799
  // tool-set load has not been confirmed yet (PendingToolLoad).
3713
3800
  let incomplete_session_capture_agents =
3714
3801
  launch.session_capture_incomplete_agents.clone();
3715
- let all_spawned = !launch.started.is_empty();
3802
+ let agent_ids: Vec<_> = launch
3803
+ .started
3804
+ .iter()
3805
+ .map(|agent| agent.agent_id.as_str())
3806
+ .chain(launch.contract_started.iter().map(|id| id.as_str()))
3807
+ .collect();
3808
+ let all_spawned = !agent_ids.is_empty();
3716
3809
  let leader_receiver_attached = launch.leader_receiver_attached;
3717
3810
  let all_resumable_have_session = incomplete_session_capture_agents.is_empty();
3718
3811
  let all_workers_spawned = all_spawned;
@@ -3840,7 +3933,7 @@ pub mod lifecycle_port {
3840
3933
  "ready": readiness_json.get("ready").cloned().unwrap_or(Value::Bool(false)),
3841
3934
  "session_name": session_name.as_str(),
3842
3935
  "team": team,
3843
- "agent_ids": launch.started.iter().map(|agent| agent.agent_id.as_str()).collect::<Vec<_>>(),
3936
+ "agent_ids": agent_ids,
3844
3937
  "dry_run": launch.dry_run,
3845
3938
  "next_actions": next_actions,
3846
3939
  "attach_commands": attach_commands,
@@ -3961,9 +4054,8 @@ pub mod lifecycle_port {
3961
4054
  .is_some());
3962
4055
  }
3963
4056
 
3964
- #[test]
3965
- fn pending_tool_load_success_output_explains_bound_send_without_doctor() {
3966
- let value = quick_start_value(crate::lifecycle::QuickStartReport::Ready {
4057
+ fn pending_tool_load_report() -> crate::lifecycle::QuickStartReport {
4058
+ crate::lifecycle::QuickStartReport::Ready {
3967
4059
  session_name: crate::transport::SessionName::new("team-demo"),
3968
4060
  launch: Box::new(crate::lifecycle::LaunchReport {
3969
4061
  session_name: crate::transport::SessionName::new("team-demo"),
@@ -3979,6 +4071,7 @@ pub mod lifecycle_port {
3979
4071
  provider_projects_root: None,
3980
4072
  managed_mcp_config: false,
3981
4073
  }],
4074
+ contract_started: Vec::new(),
3982
4075
  dry_run: false,
3983
4076
  tmux_endpoint: None,
3984
4077
  routes: Vec::new(),
@@ -3991,7 +4084,63 @@ pub mod lifecycle_port {
3991
4084
  attach_commands: Vec::new(),
3992
4085
  worker_readiness: crate::lifecycle::QuickStartReadiness::PendingToolLoad,
3993
4086
  team: "team-demo".to_string(),
3994
- });
4087
+ }
4088
+ }
4089
+
4090
+ #[test]
4091
+ fn contract_start_receipts_join_summary_without_bypassing_leader_binding() {
4092
+ for (legacy, contract) in [(false, true), (true, true), (true, false), (false, false)] {
4093
+ for bound in [false, true] {
4094
+ let mut report = pending_tool_load_report();
4095
+ let crate::lifecycle::QuickStartReport::Ready { launch, .. } = &mut report else {
4096
+ panic!("ready fixture required");
4097
+ };
4098
+ if !legacy {
4099
+ launch.started.clear();
4100
+ }
4101
+ if contract {
4102
+ launch
4103
+ .contract_started
4104
+ .push(crate::model::ids::AgentId::new("kiro-worker"));
4105
+ }
4106
+ launch.leader_receiver_attached = bound;
4107
+ launch.leader_bind_stage = (!bound).then(|| "caller_pane".into());
4108
+ launch.leader_bind_reason = (!bound).then(|| "caller_pane_missing".into());
4109
+ assert_eq!(launch.started.len(), usize::from(legacy));
4110
+ let expected: Vec<_> = [(legacy, "worker"), (contract, "kiro-worker")]
4111
+ .into_iter()
4112
+ .filter_map(|(started, id)| started.then_some(id))
4113
+ .collect();
4114
+ let value = quick_start_value(report);
4115
+ assert_eq!(value["agent_ids"], json!(expected));
4116
+ assert_eq!(
4117
+ value["readiness"]["all_workers_spawned"],
4118
+ json!(legacy || contract)
4119
+ );
4120
+ assert_eq!(value["readiness"]["all_spawned"], json!(legacy || contract));
4121
+ assert_eq!(value["readiness"]["leader_receiver_attached"], json!(bound));
4122
+ assert_eq!(value["ok"], json!((legacy || contract) && bound));
4123
+ assert_eq!(value["ready"], json!((legacy || contract) && bound));
4124
+ if !bound {
4125
+ assert_eq!(value["status"], "leader_binding_incomplete");
4126
+ assert_eq!(value["reason"], "caller_pane_missing");
4127
+ } else {
4128
+ assert_eq!(value["status"], "pending_tool_load");
4129
+ }
4130
+ let mut compact = value.clone();
4131
+ compact_quick_start_value(&mut compact);
4132
+ assert_eq!(
4133
+ compact["worker_readiness"]["all_workers_spawned"],
4134
+ json!(legacy || contract)
4135
+ );
4136
+ assert_eq!(compact["ready"], value["ready"]);
4137
+ }
4138
+ }
4139
+ }
4140
+
4141
+ #[test]
4142
+ fn pending_tool_load_success_output_explains_bound_send_without_doctor() {
4143
+ let value = quick_start_value(pending_tool_load_report());
3995
4144
  assert_eq!(
3996
4145
  value.get("summary").and_then(Value::as_str),
3997
4146
  Some("team started; leader bound; send a task next (worker tool load unverified): team-demo")