@team-agent/installer 0.3.1 → 0.3.3

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 (79) hide show
  1. package/Cargo.lock +34 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/Cargo.toml +1 -1
  4. package/crates/team-agent/src/cli/adapters.rs +234 -26
  5. package/crates/team-agent/src/cli/diagnose.rs +144 -10
  6. package/crates/team-agent/src/cli/emit.rs +289 -54
  7. package/crates/team-agent/src/cli/leader.rs +37 -8
  8. package/crates/team-agent/src/cli/mod.rs +1281 -196
  9. package/crates/team-agent/src/cli/status_port.rs +195 -46
  10. package/crates/team-agent/src/cli/tests/divergence.rs +1 -2
  11. package/crates/team-agent/src/cli/tests/lane_c.rs +23 -13
  12. package/crates/team-agent/src/cli/tests/main_preserved.rs +2 -0
  13. package/crates/team-agent/src/cli/tests/run_delegation.rs +59 -3
  14. package/crates/team-agent/src/cli/types.rs +18 -0
  15. package/crates/team-agent/src/compiler.rs +15 -5
  16. package/crates/team-agent/src/coordinator/health.rs +95 -17
  17. package/crates/team-agent/src/coordinator/mod.rs +4 -0
  18. package/crates/team-agent/src/coordinator/runtime_detectors.rs +500 -0
  19. package/crates/team-agent/src/coordinator/runtime_observation.rs +58 -0
  20. package/crates/team-agent/src/coordinator/tick.rs +222 -69
  21. package/crates/team-agent/src/coordinator/types.rs +15 -3
  22. package/crates/team-agent/src/db/schema.rs +37 -2
  23. package/crates/team-agent/src/diagnose/comms.rs +226 -0
  24. package/crates/team-agent/src/diagnose/mod.rs +45 -0
  25. package/crates/team-agent/src/diagnose/orphans.rs +658 -0
  26. package/crates/team-agent/src/fake_worker.rs +146 -3
  27. package/crates/team-agent/src/leader/start.rs +121 -23
  28. package/crates/team-agent/src/leader/types.rs +44 -1
  29. package/crates/team-agent/src/lib.rs +3 -0
  30. package/crates/team-agent/src/lifecycle/display.rs +645 -47
  31. package/crates/team-agent/src/lifecycle/launch.rs +1061 -146
  32. package/crates/team-agent/src/lifecycle/mod.rs +2 -0
  33. package/crates/team-agent/src/lifecycle/profile_launch.rs +810 -0
  34. package/crates/team-agent/src/lifecycle/profile_smoke.rs +522 -0
  35. package/crates/team-agent/src/lifecycle/restart/agent.rs +99 -23
  36. package/crates/team-agent/src/lifecycle/restart/common.rs +183 -24
  37. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +498 -22
  38. package/crates/team-agent/src/lifecycle/restart/remove.rs +27 -7
  39. package/crates/team-agent/src/lifecycle/restart/team_state.rs +19 -0
  40. package/crates/team-agent/src/lifecycle/restart.rs +24 -1
  41. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +5 -5
  42. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +37 -7
  43. package/crates/team-agent/src/lifecycle/types.rs +19 -0
  44. package/crates/team-agent/src/mcp_server/helpers.rs +1 -0
  45. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +341 -0
  46. package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +10 -0
  47. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +158 -0
  48. package/crates/team-agent/src/mcp_server/mod.rs +3 -74
  49. package/crates/team-agent/src/mcp_server/tests/scoped.rs +1 -1
  50. package/crates/team-agent/src/mcp_server/tests/send.rs +6 -5
  51. package/crates/team-agent/src/mcp_server/tools.rs +312 -111
  52. package/crates/team-agent/src/mcp_server/types.rs +6 -4
  53. package/crates/team-agent/src/mcp_server/wire.rs +19 -7
  54. package/crates/team-agent/src/message_store.rs +21 -4
  55. package/crates/team-agent/src/messaging/delivery.rs +470 -59
  56. package/crates/team-agent/src/messaging/mod.rs +9 -6
  57. package/crates/team-agent/src/messaging/results.rs +353 -63
  58. package/crates/team-agent/src/messaging/selftest.rs +199 -12
  59. package/crates/team-agent/src/messaging/send.rs +35 -3
  60. package/crates/team-agent/src/messaging/tests/runtime.rs +19 -4
  61. package/crates/team-agent/src/messaging/types.rs +11 -3
  62. package/crates/team-agent/src/os_probe.rs +119 -0
  63. package/crates/team-agent/src/packaging/migrate.rs +10 -2
  64. package/crates/team-agent/src/packaging/tests.rs +23 -0
  65. package/crates/team-agent/src/provider/adapter.rs +564 -63
  66. package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +1 -7
  67. package/crates/team-agent/src/provider/classify.rs +51 -4
  68. package/crates/team-agent/src/provider/helpers.rs +10 -1
  69. package/crates/team-agent/src/provider/startup_prompt.rs +94 -0
  70. package/crates/team-agent/src/provider/types.rs +47 -0
  71. package/crates/team-agent/src/session_capture.rs +616 -0
  72. package/crates/team-agent/src/state/persist.rs +170 -1
  73. package/crates/team-agent/src/state/projection.rs +141 -8
  74. package/crates/team-agent/src/state/selector.rs +5 -2
  75. package/crates/team-agent/src/tmux_backend.rs +161 -64
  76. package/crates/team-agent/src/transport/test_support.rs +9 -0
  77. package/crates/team-agent/src/transport/tests/wire.rs +4 -0
  78. package/crates/team-agent/src/transport.rs +13 -2
  79. package/package.json +4 -4
@@ -10,7 +10,6 @@ use crate::model::ids::{AgentId, TaskId, TeamKey};
10
10
 
11
11
  // ── REUSE: step 4 event_log / step 7 message_store ──────────────────────────
12
12
  use crate::event_log::EventLog;
13
- use crate::message_store::MessageStore;
14
13
 
15
14
  // ── REUSE: step 5 state persist / projection ────────────────────────────────
16
15
  use crate::state::persist::{load_runtime_state, save_runtime_state};
@@ -24,7 +23,7 @@ use super::helpers::{
24
23
  requires_ack_for_target, tool_runtime_error,
25
24
  };
26
25
  use super::normalize::{compact_tool_result, normalize_report_envelope};
27
- use super::types::{McpError, Scope, SendOutcome, ToolError, ToolErrorReason, ToolOk, ToolResult, VisiblePeers};
26
+ use super::types::{Scope, SendOutcome, ToolError, ToolErrorReason, ToolOk, ToolResult, VisiblePeers};
28
27
 
29
28
  // ═══════════════════════════════════════════════════════════════════════════
30
29
  // TeamOrchestratorTools (tools.py:72) — the 12 typed tool handlers.
@@ -75,6 +74,7 @@ impl TeamOrchestratorTools {
75
74
  /// field-updates the task in state, then delegates delivery to
76
75
  /// [`Self::send_message`] and compacts the result.
77
76
  pub fn assign_task(&self, task: &Value, message: Option<&str>) -> ToolResult {
77
+ self.validate_rpc_scope_args("assign_task", task)?;
78
78
  let Some(task_obj) = task.as_object() else {
79
79
  return Err(ToolError::new(
80
80
  ToolErrorReason::InvalidToolArguments,
@@ -104,7 +104,10 @@ impl TeamOrchestratorTools {
104
104
  let task_value = Value::Object(task_obj.clone());
105
105
  let mut state = load_runtime_state(&self.workspace).map_err(tool_runtime_error)?;
106
106
  ensure_object(&mut state);
107
- let team_key = assignment_team_key(&state, self.owner_team_id.as_ref());
107
+ let team_key = self
108
+ .canonical_owner_team_key()?
109
+ .map(|team| team.as_str().to_string())
110
+ .or_else(|| assignment_team_key(&state));
108
111
  reconcile_assigned_task(&mut state, team_key.as_deref(), &task_value);
109
112
  save_runtime_state(&self.workspace, &state).map_err(tool_runtime_error)?;
110
113
 
@@ -136,9 +139,17 @@ impl TeamOrchestratorTools {
136
139
  task_id: Option<&str>,
137
140
  sender: Option<&str>,
138
141
  requires_ack: Option<bool>,
139
- scope: Option<Scope>,
142
+ scope_override: Option<Scope>,
140
143
  ) -> Result<SendOutcome, ToolError> {
141
- if let Some(err) = self.refuse_cross_team_peer(to, scope) {
144
+ let canonical_owner_team = self.canonical_owner_team_key()?;
145
+ if matches!(scope_override, Some(Scope::Workspace)) {
146
+ return Err(self.rpc_scope_refused(
147
+ "send_message",
148
+ None,
149
+ scope_override.and_then(scope_override_name),
150
+ ));
151
+ }
152
+ if let Some(err) = self.refuse_cross_team_peer(to, None) {
142
153
  return Err(err);
143
154
  }
144
155
  let sender = sender
@@ -156,7 +167,7 @@ impl TeamOrchestratorTools {
156
167
  serde_json::json!({
157
168
  "tool": "send_message",
158
169
  "sender": sender,
159
- "owner_team_id": self.owner_team_id.as_ref().map(TeamKey::as_str),
170
+ "owner_team_id": canonical_owner_team.as_ref().map(TeamKey::as_str),
160
171
  "to": match to {
161
172
  MessageTarget::Single(t) => serde_json::Value::String(t.clone()),
162
173
  MessageTarget::Broadcast => serde_json::Value::String("*".to_string()),
@@ -168,42 +179,77 @@ impl TeamOrchestratorTools {
168
179
  }),
169
180
  )
170
181
  .map_err(tool_runtime_error)?;
171
- if is_worker_recipient(to) {
172
- let recipient = match to {
173
- MessageTarget::Single(value) => value.as_str(),
174
- MessageTarget::Broadcast | MessageTarget::Fanout(_) => "worker",
175
- };
176
- let store = MessageStore::open(&self.workspace).map_err(tool_runtime_error)?;
177
- let message_id = store
178
- .create_message(
179
- task_id,
180
- sender,
181
- recipient,
182
- content,
183
- None,
184
- ack,
185
- self.owner_team_id.as_ref().map(TeamKey::as_str),
186
- )
187
- .map_err(tool_runtime_error)?;
188
- return Ok(SendOutcome::WorkerAccepted {
189
- poll_via: format!("team-agent inbox {message_id}"),
190
- message_id,
191
- });
192
- }
193
182
  let opts = SendOptions {
194
183
  task_id: task_id.map(TaskId::new),
195
184
  route_task_id: true,
196
185
  sender: sender.to_string(),
197
186
  requires_ack: ack,
198
- team: self.owner_team_id.clone(),
187
+ team: canonical_owner_team,
199
188
  ..SendOptions::default()
200
189
  };
190
+ if is_worker_recipient(to) {
191
+ let out = messaging::send_message(&self.workspace, to, content, &opts).map_err(tool_runtime_error)?;
192
+ let message_id = match out.message_id {
193
+ Some(message_id) if out.ok => message_id,
194
+ None if self.owner_team_id.is_none() => {
195
+ format!("mcp_{}", chrono::Utc::now().timestamp_micros())
196
+ }
197
+ _ => {
198
+ let value = delivery_outcome_value(&out);
199
+ let ok = compact_tool_result(&value)?;
200
+ return Ok(SendOutcome::Direct(ok));
201
+ }
202
+ };
203
+ return Ok(SendOutcome::WorkerAccepted {
204
+ poll_via: format!("team-agent inbox {message_id}"),
205
+ message_id,
206
+ });
207
+ }
201
208
  let out = messaging::send_message(&self.workspace, to, content, &opts).map_err(tool_runtime_error)?;
202
209
  let value = delivery_outcome_value(&out);
203
210
  let ok = compact_tool_result(&value)?;
204
211
  Ok(SendOutcome::Direct(ok))
205
212
  }
206
213
 
214
+ pub(crate) fn refuse_scope_override(&self) -> ToolError {
215
+ self.rpc_scope_refused("unknown", None, None)
216
+ }
217
+
218
+ pub(crate) fn validate_rpc_scope_args(&self, tool: &str, args: &Value) -> Result<(), ToolError> {
219
+ if let Some(nested) = args.get("task").or_else(|| args.get("envelope")) {
220
+ self.validate_rpc_scope_args(tool, nested)?;
221
+ }
222
+ let owner_team = self.canonical_owner_team_key()?;
223
+ let requested_team = requested_team_arg(args);
224
+ let requested_scope = requested_scope_arg(args);
225
+ let workspace_override = args.get("workspace").is_some();
226
+ let scope_widens = requested_scope
227
+ .as_deref()
228
+ .is_some_and(|scope| !scope.eq_ignore_ascii_case("team"));
229
+ let team_widens = match (owner_team.as_ref(), requested_team.as_deref()) {
230
+ (_, None) => false,
231
+ (Some(owner), Some(requested)) => {
232
+ let state = load_runtime_state(&self.workspace).unwrap_or(serde_json::json!({}));
233
+ let requested_canonical = crate::state::projection::resolve_owner_team_id(&state, requested)
234
+ .canonical_key()
235
+ .unwrap_or(requested)
236
+ .to_string();
237
+ requested_canonical != owner.as_str()
238
+ }
239
+ (None, Some(_)) => true,
240
+ };
241
+ if workspace_override || scope_widens || team_widens {
242
+ return Err(self.rpc_scope_refused(
243
+ tool,
244
+ requested_team.as_deref(),
245
+ requested_scope
246
+ .as_deref()
247
+ .or_else(|| workspace_override.then_some("workspace")),
248
+ ));
249
+ }
250
+ Ok(())
251
+ }
252
+
207
253
  /// `report_result` (`tools.py:249-279`): build & normalize the result envelope
208
254
  /// (inferring `task_id`/`agent_id` with byte-stable `"manual"`/`"unknown"`
209
255
  /// fallbacks), then delegate to [`messaging::report_result`] and compact.
@@ -221,6 +267,9 @@ impl TeamOrchestratorTools {
221
267
  task_id: Option<&str>,
222
268
  agent_id: Option<&str>,
223
269
  ) -> ToolResult {
270
+ if let Some(envelope) = envelope {
271
+ self.validate_rpc_scope_args("report_result", envelope)?;
272
+ }
224
273
  let mut base = envelope.cloned().unwrap_or_else(|| Value::Object(serde_json::Map::new()));
225
274
  ensure_object(&mut base);
226
275
  if let Some(obj) = base.as_object_mut() {
@@ -267,93 +316,92 @@ impl TeamOrchestratorTools {
267
316
  }
268
317
  let normalized = normalize_report_envelope(&base);
269
318
  let env_value = normalized_envelope_value(&normalized);
270
- messaging::report_result(&self.workspace, &env_value)
319
+ let owner_team = self.canonical_owner_team_key()?;
320
+ messaging::report_result_for_owner_team(
321
+ &self.workspace,
322
+ &env_value,
323
+ owner_team.as_ref().map(TeamKey::as_str),
324
+ )
271
325
  .map_err(tool_runtime_error)
272
326
  .and_then(|value| compact_tool_result(&value))
273
327
  }
274
328
 
275
- /// `update_state` (`tools.py:316-325`): append a note to `state.notes`, save, then
276
- /// rewrite `team_state.md` (delegated to step 13 [`write_team_state`]). Returns
277
- /// `{ok:true, state_file:<path>}`.
278
- ///
279
- /// [`write_team_state`]: super::lifecycle_placeholder::write_team_state
329
+ /// `update_state` (`tools.py:316-325`): delegated through the lifecycle tools
330
+ /// facade. S0 preserves the old placeholder behavior.
280
331
  pub fn update_state(&self, note: &str) -> ToolResult {
281
- let mut state = load_runtime_state(&self.workspace).map_err(tool_runtime_error)?;
282
- ensure_object(&mut state);
283
- if let Some(obj) = state.as_object_mut() {
284
- let notes = obj
285
- .entry("notes".to_string())
286
- .or_insert_with(|| Value::Array(Vec::new()));
287
- if !notes.is_array() {
288
- *notes = Value::Array(Vec::new());
289
- }
290
- if let Some(items) = notes.as_array_mut() {
291
- items.push(Value::String(note.to_string()));
292
- }
293
- }
294
- save_runtime_state(&self.workspace, &state).map_err(tool_runtime_error)?;
295
- let path = super::lifecycle_placeholder::write_team_state(&self.workspace, &Value::Null, &state)
296
- .map_err(tool_runtime_error)?;
297
- let mut fields = serde_json::Map::new();
298
- fields.insert("ok".to_string(), Value::Bool(true));
299
- fields.insert("state_file".to_string(), Value::String(path.to_string_lossy().to_string()));
300
- Ok(ToolOk { fields })
332
+ let owner_team = self.canonical_owner_team_key()?;
333
+ super::lifecycle_tools::update_state(&self.workspace, owner_team.as_ref(), note)
301
334
  }
302
335
 
303
- /// `get_team_status` (`tools.py:327-328`): machine-readable status
304
- /// `runtime.status(workspace, as_json=true, compact=true)` (delegated to step 13
305
- /// [`runtime_status`]). Returns the compact status object verbatim.
306
- ///
307
- /// [`runtime_status`]: super::lifecycle_placeholder::runtime_status
336
+ /// `get_team_status` (`tools.py:327-328`): delegated through the lifecycle tools
337
+ /// facade. S0 preserves the old placeholder behavior.
308
338
  pub fn get_team_status(&self) -> ToolResult {
309
- match super::lifecycle_placeholder::runtime_status(&self.workspace, true) {
310
- Ok(value) => Ok(ToolOk { fields: object_fields(value) }),
311
- Err(err) => Err(tool_runtime_error(err)),
312
- }
339
+ let owner_team = self.canonical_owner_team_key()?;
340
+ super::lifecycle_tools::get_team_status(&self.workspace, owner_team.as_ref())
313
341
  }
314
342
 
315
- /// `stop_agent` (`tools.py:330-331`): delegate to step 13 [`stop_agent`], compact.
316
- ///
317
- /// [`stop_agent`]: super::lifecycle_placeholder::stop_agent
343
+ /// `stop_agent` (`tools.py:330-331`): delegated through the lifecycle tools facade.
318
344
  pub fn stop_agent(&self, agent_id: &str) -> ToolResult {
319
- super::lifecycle_placeholder::stop_agent(&self.workspace, agent_id)
320
- .map_err(tool_runtime_error)
321
- .and_then(|v| compact_tool_result(&v))
345
+ let owner_team = self.canonical_owner_team_key()?;
346
+ super::lifecycle_tools::stop_agent(&self.workspace, owner_team.as_ref(), agent_id)
322
347
  }
323
348
 
324
- /// `reset_agent` (`tools.py:333-334`): delegate to step 13 [`reset_agent`]
325
- /// (`discard_session`), compact.
326
- ///
327
- /// [`reset_agent`]: super::lifecycle_placeholder::reset_agent
349
+ /// `reset_agent` (`tools.py:333-334`): delegated through the lifecycle tools facade.
328
350
  pub fn reset_agent(&self, agent_id: &str, discard_session: bool) -> ToolResult {
329
- super::lifecycle_placeholder::reset_agent(&self.workspace, agent_id, discard_session)
330
- .map_err(tool_runtime_error)
331
- .and_then(|v| compact_tool_result(&v))
351
+ let owner_team = self.canonical_owner_team_key()?;
352
+ super::lifecycle_tools::reset_agent(&self.workspace, owner_team.as_ref(), agent_id, discard_session)
332
353
  }
333
354
 
334
- /// `add_agent` (`tools.py:336-337`): delegate to step 13 [`add_agent`]
335
- /// (workspace-relative role file), compact.
336
- ///
337
- /// [`add_agent`]: super::lifecycle_placeholder::add_agent
355
+ /// `add_agent` (`tools.py:336-337`): delegate to real lifecycle add-agent
356
+ /// under the spawn-time owner team.
338
357
  pub fn add_agent(&self, new_agent_id: &str, role_file_path: &str) -> ToolResult {
339
- super::lifecycle_placeholder::add_agent(&self.workspace, new_agent_id, role_file_path)
340
- .map_err(tool_runtime_error)
341
- .and_then(|v| compact_tool_result(&v))
358
+ let owner_team = self
359
+ .canonical_owner_team_key()?
360
+ .ok_or_else(|| self.scope_refused("add_agent requires TEAM_AGENT_OWNER_TEAM_ID"))?;
361
+ let role_file = Path::new(role_file_path);
362
+ let role_file = if role_file.is_absolute() {
363
+ role_file.to_path_buf()
364
+ } else {
365
+ self.workspace.join(role_file)
366
+ };
367
+ crate::lifecycle::launch::add_agent(
368
+ &self.workspace,
369
+ &AgentId::new(new_agent_id.to_string()),
370
+ &role_file,
371
+ false,
372
+ Some(owner_team.as_str()),
373
+ )
374
+ .map_err(tool_runtime_error)
375
+ .and_then(|report| {
376
+ compact_tool_result(&serde_json::json!({
377
+ "ok": true,
378
+ "status": "added",
379
+ "agent_id": new_agent_id,
380
+ "state_file": report.env.state_file.to_string_lossy().to_string(),
381
+ "coordinator_started": report.env.coordinator_started,
382
+ "start_mode": format!("{:?}", report.start_mode),
383
+ "role_file": report.role_file.to_string_lossy().to_string(),
384
+ }))
385
+ })
342
386
  }
343
387
 
344
- /// `fork_agent` (`tools.py:339-340`): delegate to step 13 [`fork_agent`], compact.
345
- ///
346
- /// [`fork_agent`]: super::lifecycle_placeholder::fork_agent
388
+ /// `fork_agent` (`tools.py:339-340`): delegated through the lifecycle tools facade.
347
389
  pub fn fork_agent(&self, source_agent_id: &str, as_agent_id: &str, label: Option<&str>) -> ToolResult {
348
- super::lifecycle_placeholder::fork_agent(&self.workspace, source_agent_id, as_agent_id, label)
349
- .map_err(tool_runtime_error)
350
- .and_then(|v| compact_tool_result(&v))
390
+ let owner_team = self.canonical_owner_team_key()?;
391
+ super::lifecycle_tools::fork_agent(
392
+ &self.workspace,
393
+ owner_team.as_ref(),
394
+ source_agent_id,
395
+ as_agent_id,
396
+ label,
397
+ )
351
398
  }
352
399
 
353
400
  /// `request_human` (`tools.py:342-346`): create a `requires_ack` leader message via
354
- /// [`MessageStore::create_message`]; sender = env / inferred / `"unknown"`. Returns
355
- /// `{ok:true, message_id, status:"needs_human"}`.
401
+ /// the shared leader-delivery funnel; sender = env / inferred / `"unknown"`.
402
+ /// Returns `{ok:true, message_id, status:"needs_human"}`.
356
403
  pub fn request_human(&self, question: &str, task_id: Option<&str>, agent_id: Option<&str>) -> ToolResult {
404
+ let _owner_team = self.canonical_owner_team_key()?;
357
405
  let explicit_sender = agent_id.and_then(non_empty_string);
358
406
  let sender = explicit_sender
359
407
  .or_else(|| self.agent_id.as_ref().map(AgentId::as_str))
@@ -369,7 +417,7 @@ impl TeamOrchestratorTools {
369
417
  }
370
418
  // #230 N31/N32 funnel: request_human is a leader-bound caller and must go through
371
419
  // the same primitive as send_message(to=leader) / report_result / idle reminder.
372
- // The legacy path was a raw `store.create_message(... recipient="leader" ...)` that
420
+ // The legacy path was a raw store insert for recipient="leader" that
373
421
  // bypassed the leader-delivery audit (no deliver_to_leader.submit emit, no rebind
374
422
  // guard, no leader_notification_log dedup). funnel it now.
375
423
  let state = crate::state::persist::load_runtime_state(&self.workspace)
@@ -400,6 +448,7 @@ impl TeamOrchestratorTools {
400
448
  /// `stuck_list` (`tools.py:348-349`): delegate to [`messaging::stuck_list`] (the
401
449
  /// team-scoped suppressed-alert projection).
402
450
  pub fn stuck_list(&self) -> ToolResult {
451
+ let _owner_team = self.canonical_owner_team_key()?;
403
452
  messaging::stuck_list(&self.workspace)
404
453
  .map_err(tool_runtime_error)
405
454
  .map(|v| ToolOk { fields: object_fields(v) })
@@ -408,6 +457,7 @@ impl TeamOrchestratorTools {
408
457
  /// `stuck_cancel` (`tools.py:351-352`): delegate to [`messaging::stuck_cancel`];
409
458
  /// `suppressed_by` = env agent_id / `"leader"`.
410
459
  pub fn stuck_cancel(&self, agent_id: &str, alert_type: &str) -> ToolResult {
460
+ let _owner_team = self.canonical_owner_team_key()?;
411
461
  let alert = match alert_type {
412
462
  "stuck" => Some(messaging::AlertType::Stuck),
413
463
  "idle_fallback" => Some(messaging::AlertType::IdleFallback),
@@ -424,10 +474,10 @@ impl TeamOrchestratorTools {
424
474
  /// `get_visible_peers` (`tools.py:226-247`): C16 scope-filtered peer list — live
425
475
  /// agents within the spawn-time owner-team scope only; other teams and dead/stopped
426
476
  /// agents are filtered server-side and never named.
427
- pub fn get_visible_peers(&self) -> Result<VisiblePeers, McpError> {
477
+ pub fn get_visible_peers(&self) -> Result<VisiblePeers, ToolError> {
428
478
  let mut peers = Vec::new();
429
- if let Some(team) = &self.owner_team_id {
430
- let state = load_runtime_state(&self.workspace)?;
479
+ if let Some(team) = self.canonical_owner_team_key_for_mcp()? {
480
+ let state = load_runtime_state(&self.workspace).map_err(tool_runtime_error)?;
431
481
  if let Some(agents) = state
432
482
  .get("teams")
433
483
  .and_then(|v| v.get(team.as_str()))
@@ -451,7 +501,7 @@ impl TeamOrchestratorTools {
451
501
  peers.sort_by(|a, b| a.as_str().cmp(b.as_str()));
452
502
  Ok(VisiblePeers {
453
503
  peers,
454
- sender_team_id: self.owner_team_id.clone(),
504
+ sender_team_id: self.canonical_owner_team_key_for_mcp()?,
455
505
  scope: Scope::Team,
456
506
  })
457
507
  }
@@ -460,8 +510,19 @@ impl TeamOrchestratorTools {
460
510
  /// non-`*`/non-leader string target NOT in the visible-peer scope and not the
461
511
  /// sender itself, with `scope != workspace`, → `Some(ToolError{PeerNotInScope})`
462
512
  /// (also writes `mcp.send_message_refused`). `None` = allowed to proceed.
463
- pub fn refuse_cross_team_peer(&self, to: &MessageTarget, scope: Option<Scope>) -> Option<ToolError> {
464
- if scope == Some(Scope::Workspace) || self.owner_team_id.is_none() {
513
+ pub fn refuse_cross_team_peer(&self, to: &MessageTarget, scope_override: Option<Scope>) -> Option<ToolError> {
514
+ let owner_team = match self.canonical_owner_team_key() {
515
+ Ok(team) => team,
516
+ Err(error) => return Some(error),
517
+ };
518
+ if matches!(scope_override, Some(Scope::Workspace)) {
519
+ return Some(self.rpc_scope_refused(
520
+ "send_message",
521
+ None,
522
+ scope_override.and_then(scope_override_name),
523
+ ));
524
+ }
525
+ if owner_team.is_none() {
465
526
  return None;
466
527
  }
467
528
  let MessageTarget::Single(target) = to else {
@@ -480,12 +541,12 @@ impl TeamOrchestratorTools {
480
541
  return None;
481
542
  }
482
543
  }
483
- let hint = "the requested peer is not part of your team. pass scope='workspace' to address peers in other teams.";
544
+ let hint = "the requested peer is not part of your team; worker-origin MCP cannot widen team scope.";
484
545
  let _ = EventLog::new(&self.workspace).write(
485
546
  "mcp.send_message_refused",
486
547
  serde_json::json!({
487
548
  "reason": "peer_not_in_scope",
488
- "sender_team_id": self.owner_team_id.as_ref().map(TeamKey::as_str).unwrap_or(""),
549
+ "sender_team_id": owner_team.as_ref().map(TeamKey::as_str).unwrap_or(""),
489
550
  "scope": "team",
490
551
  "hint": hint
491
552
  }),
@@ -503,18 +564,151 @@ impl TeamOrchestratorTools {
503
564
  extra,
504
565
  })
505
566
  }
567
+
568
+ fn canonical_owner_team_key(&self) -> Result<Option<TeamKey>, ToolError> {
569
+ // Single worker MCP owner resolver: TEAM_AGENT_OWNER_TEAM_ID must resolve
570
+ // through state::projection::resolve_owner_team_id to the runtime team key.
571
+ // Unresolved/ambiguous owner scope emits mcp.scope_refused; never fallback
572
+ // to active/top-level/sibling teams in a multi-team state.
573
+ let Some(owner_team_id) = &self.owner_team_id else {
574
+ let state = load_runtime_state(&self.workspace).unwrap_or(serde_json::json!({}));
575
+ if state
576
+ .get("teams")
577
+ .and_then(Value::as_object)
578
+ .is_some_and(|teams| !teams.is_empty())
579
+ {
580
+ return Err(self.scope_refused("TEAM_AGENT_OWNER_TEAM_ID is required for multi-team MCP"));
581
+ }
582
+ return Ok(None);
583
+ };
584
+ let state = load_runtime_state(&self.workspace)
585
+ .map_err(|_| self.scope_refused("owner team could not be resolved"))?;
586
+ match canonicalize_owner_team_id(&state, owner_team_id.as_str()) {
587
+ Some(team) => Ok(Some(TeamKey::new(team))),
588
+ None => Err(self.scope_refused("owner team could not be resolved")),
589
+ }
590
+ }
591
+
592
+ fn canonical_owner_team_key_for_mcp(&self) -> Result<Option<TeamKey>, ToolError> {
593
+ let Some(owner_team_id) = &self.owner_team_id else {
594
+ let state = load_runtime_state(&self.workspace).unwrap_or(serde_json::json!({}));
595
+ if state
596
+ .get("teams")
597
+ .and_then(Value::as_object)
598
+ .is_some_and(|teams| !teams.is_empty())
599
+ {
600
+ return Err(self.scope_refused("TEAM_AGENT_OWNER_TEAM_ID is required for multi-team MCP"));
601
+ }
602
+ return Ok(None);
603
+ };
604
+ let state = load_runtime_state(&self.workspace)
605
+ .map_err(|_| self.scope_refused("owner team could not be resolved"))?;
606
+ match canonicalize_owner_team_id(&state, owner_team_id.as_str()) {
607
+ Some(team) => Ok(Some(TeamKey::new(team))),
608
+ None => Err(self.scope_refused("owner team could not be resolved")),
609
+ }
610
+ }
611
+
612
+ fn scope_refused(&self, message: &str) -> ToolError {
613
+ let canonical_owner_team_id = self.canonical_owner_team_key_for_event();
614
+ let _ = EventLog::new(&self.workspace).write(
615
+ "mcp.scope_refused",
616
+ serde_json::json!({
617
+ "reason": "scope_refused",
618
+ "requested_owner_team_id": self.owner_team_id.as_ref().map(TeamKey::as_str),
619
+ "owner_team_id": canonical_owner_team_id,
620
+ "canonical_owner_team_id": canonical_owner_team_id,
621
+ "message": message,
622
+ }),
623
+ );
624
+ let mut extra = serde_json::Map::new();
625
+ extra.insert("status".to_string(), Value::String("refused".to_string()));
626
+ extra.insert("hint".to_string(), Value::String(message.to_string()));
627
+ ToolError {
628
+ reason: ToolErrorReason::McpScopeRefused,
629
+ exc_type: "McpScopeRefused".to_string(),
630
+ message: "mcp.scope_refused".to_string(),
631
+ extra,
632
+ }
633
+ }
634
+
635
+ fn rpc_scope_refused(
636
+ &self,
637
+ tool: &str,
638
+ requested_team: Option<&str>,
639
+ requested_scope: Option<&str>,
640
+ ) -> ToolError {
641
+ let owner_team_id = self.canonical_owner_team_key_for_event();
642
+ let agent_id = self.agent_id.as_ref().map(AgentId::as_str).unwrap_or("unknown");
643
+ let _ = EventLog::new(&self.workspace).write(
644
+ "mcp.scope_refused",
645
+ serde_json::json!({
646
+ "reason": "rpc_scope_override",
647
+ "tool": tool,
648
+ "agent_id": agent_id,
649
+ "owner_team_id": owner_team_id,
650
+ "requested_team": requested_team,
651
+ "requested_scope": requested_scope,
652
+ }),
653
+ );
654
+ let mut extra = serde_json::Map::new();
655
+ extra.insert("status".to_string(), Value::String("refused".to_string()));
656
+ extra.insert("tool".to_string(), Value::String(tool.to_string()));
657
+ extra.insert("agent_id".to_string(), Value::String(agent_id.to_string()));
658
+ extra.insert(
659
+ "owner_team_id".to_string(),
660
+ owner_team_id.map_or(Value::Null, Value::String),
661
+ );
662
+ extra.insert(
663
+ "requested_team".to_string(),
664
+ requested_team.map_or(Value::Null, |team| Value::String(team.to_string())),
665
+ );
666
+ extra.insert(
667
+ "requested_scope".to_string(),
668
+ requested_scope.map_or(Value::Null, |scope| Value::String(scope.to_string())),
669
+ );
670
+ ToolError {
671
+ reason: ToolErrorReason::McpScopeRefused,
672
+ exc_type: "McpScopeRefused".to_string(),
673
+ message: "mcp.scope_refused".to_string(),
674
+ extra,
675
+ }
676
+ }
677
+
678
+ fn canonical_owner_team_key_for_event(&self) -> Option<String> {
679
+ let owner_team_id = self.owner_team_id.as_ref()?;
680
+ let state = load_runtime_state(&self.workspace).ok()?;
681
+ canonicalize_owner_team_id(&state, owner_team_id.as_str())
682
+ }
506
683
  }
507
684
 
508
- fn assignment_team_key(state: &Value, owner_team_id: Option<&TeamKey>) -> Option<String> {
509
- owner_team_id
510
- .map(|team| team.as_str().to_string())
511
- .or_else(|| {
512
- state
513
- .get("active_team_key")
514
- .and_then(Value::as_str)
515
- .and_then(non_empty_string)
516
- .map(ToString::to_string)
517
- })
685
+ fn canonicalize_owner_team_id(state: &Value, owner_team_id: &str) -> Option<String> {
686
+ crate::state::projection::resolve_owner_team_id(state, owner_team_id)
687
+ .canonical_key()
688
+ .map(ToString::to_string)
689
+ }
690
+
691
+ fn requested_team_arg(args: &Value) -> Option<String> {
692
+ ["team", "team_id", "owner_team_id", "owner_team", "target_team"]
693
+ .iter()
694
+ .find_map(|key| args.get(*key).and_then(Value::as_str).filter(|s| !s.is_empty()))
695
+ .map(ToString::to_string)
696
+ }
697
+
698
+ fn requested_scope_arg(args: &Value) -> Option<String> {
699
+ args.get("scope")
700
+ .and_then(Value::as_str)
701
+ .filter(|s| !s.is_empty())
702
+ .map(ToString::to_string)
703
+ .or_else(|| args.get("workspace").map(|_| "workspace".to_string()))
704
+ }
705
+
706
+ fn assignment_team_key(state: &Value) -> Option<String> {
707
+ state
708
+ .get("active_team_key")
709
+ .and_then(Value::as_str)
710
+ .and_then(non_empty_string)
711
+ .map(ToString::to_string)
518
712
  }
519
713
 
520
714
  fn reconcile_assigned_task(state: &mut Value, team_key: Option<&str>, task: &Value) {
@@ -601,3 +795,10 @@ fn assignment_message(task: &Value, explicit: Option<&str>) -> String {
601
795
  }
602
796
  json_dumps_default(task)
603
797
  }
798
+
799
+ fn scope_override_name(scope: Scope) -> Option<&'static str> {
800
+ match scope {
801
+ Scope::Team => Some("team"),
802
+ Scope::Workspace => Some("workspace"),
803
+ }
804
+ }
@@ -152,13 +152,15 @@ pub enum ToolErrorReason {
152
152
  InvalidToolArguments,
153
153
  /// Any other exception caught (`server.py:71-72`).
154
154
  InternalRuntimeError,
155
- /// Cross-team peer addressed without `scope="workspace"` (`tools.py:211`).
155
+ /// Cross-team peer addressed outside the worker's spawn-time owner scope.
156
156
  PeerNotInScope,
157
+ /// Worker-origin RPC attempted to widen spawn-time owner scope.
158
+ #[serde(rename = "mcp.scope_refused")]
159
+ McpScopeRefused,
157
160
  }
158
161
 
159
- /// Send scope (`tools.py:165-173`). `Workspace` is the only cross-team opt-in;
160
- /// absent/`team` resolves to the spawn-time owner team. The `scope` field on
161
- /// `mcp.scope_resolved` is one of these.
162
+ /// Send scope (`tools.py:165-173`). Worker-origin MCP calls resolve to the
163
+ /// spawn-time owner team; RPC scope arguments are refused.
162
164
  #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
163
165
  #[serde(rename_all = "snake_case")]
164
166
  pub enum Scope {
@@ -318,7 +318,6 @@ fn tool_properties(tool: McpTool) -> serde_json::Map<String, Value> {
318
318
  insert_property(&mut properties, "task_id", string_property("Optional task id to associate with the message."));
319
319
  insert_property(&mut properties, "sender", string_property("Optional sender override."));
320
320
  insert_property(&mut properties, "requires_ack", boolean_property("Whether the recipient should acknowledge delivery."));
321
- insert_property(&mut properties, "scope", string_property("Optional delivery scope: team or workspace."));
322
321
  }
323
322
  McpTool::ReportResult => {
324
323
  insert_property(&mut properties, "envelope", object_property("Optional full result envelope."));
@@ -386,23 +385,21 @@ fn array_property(description: &str) -> Value {
386
385
  }
387
386
 
388
387
  pub(crate) fn dispatch_tool(tools: &TeamOrchestratorTools, tool: McpTool, args: &Value) -> ToolResult {
388
+ if scope_ceiling_tool(tool) {
389
+ tools.validate_rpc_scope_args(tool.wire_name(), args)?;
390
+ }
389
391
  match tool {
390
392
  McpTool::AssignTask => tools.assign_task(args.get("task").unwrap_or(args), args.get("message").and_then(Value::as_str)),
391
393
  McpTool::SendMessage => {
392
394
  let target = message_target_from_value(args.get("to"));
393
395
  let content = args.get("content").and_then(Value::as_str).unwrap_or("");
394
- let scope = match args.get("scope").and_then(Value::as_str) {
395
- Some("workspace") => Some(Scope::Workspace),
396
- Some("team") => Some(Scope::Team),
397
- _ => None,
398
- };
399
396
  let outcome = tools.send_message(
400
397
  &target,
401
398
  content,
402
399
  args.get("task_id").and_then(Value::as_str),
403
400
  args.get("sender").and_then(Value::as_str),
404
401
  args.get("requires_ack").and_then(Value::as_bool),
405
- scope,
402
+ None,
406
403
  )?;
407
404
  match outcome {
408
405
  SendOutcome::WorkerAccepted { .. } => Ok(ToolOk {
@@ -452,6 +449,21 @@ pub(crate) fn dispatch_tool(tools: &TeamOrchestratorTools, tool: McpTool, args:
452
449
  }
453
450
  }
454
451
 
452
+ fn scope_ceiling_tool(tool: McpTool) -> bool {
453
+ matches!(
454
+ tool,
455
+ McpTool::SendMessage
456
+ | McpTool::ReportResult
457
+ | McpTool::RequestHuman
458
+ | McpTool::AssignTask
459
+ | McpTool::UpdateState
460
+ | McpTool::GetTeamStatus
461
+ | McpTool::StopAgent
462
+ | McpTool::ResetAgent
463
+ | McpTool::ForkAgent
464
+ )
465
+ }
466
+
455
467
  fn message_target_from_value(value: Option<&Value>) -> MessageTarget {
456
468
  match value {
457
469
  Some(Value::String(s)) if s == "*" => MessageTarget::Broadcast,