@team-agent/installer 0.5.52 → 0.5.53

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 (62) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +18 -2
  4. package/crates/team-agent/src/cli/emit.rs +17 -0
  5. package/crates/team-agent/src/cli/mod.rs +155 -4
  6. package/crates/team-agent/src/cli/spec.rs +3 -0
  7. package/crates/team-agent/src/cli/types.rs +11 -0
  8. package/crates/team-agent/src/diagnose/orphans.rs +24 -3
  9. package/crates/team-agent/src/kill_audit.rs +67 -0
  10. package/crates/team-agent/src/leader/lease.rs +324 -57
  11. package/crates/team-agent/src/leader/rediscover/tests.rs +3 -0
  12. package/crates/team-agent/src/leader/rediscover.rs +6 -0
  13. package/crates/team-agent/src/leader/start.rs +144 -23
  14. package/crates/team-agent/src/leader/tests/idle.rs +3 -0
  15. package/crates/team-agent/src/leader/types.rs +6 -0
  16. package/crates/team-agent/src/lib.rs +1 -0
  17. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +1 -1
  18. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +5 -0
  19. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +4 -0
  20. package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +106 -0
  21. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +216 -208
  22. package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +36 -0
  23. package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +238 -0
  24. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +101 -5
  25. package/crates/team-agent/src/lifecycle/launch/role_source.rs +170 -0
  26. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +1 -1
  27. package/crates/team-agent/src/lifecycle/launch.rs +14 -2
  28. package/crates/team-agent/src/lifecycle/restart/agent.rs +1 -1
  29. package/crates/team-agent/src/lifecycle/restart/common.rs +12 -0
  30. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +13 -3
  31. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +110 -12
  32. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +8 -2
  33. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +1 -0
  34. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +3 -1
  35. package/crates/team-agent/src/lifecycle/types.rs +17 -0
  36. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +40 -5
  37. package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +1 -1
  38. package/crates/team-agent/src/mcp_server/tests/wire.rs +243 -167
  39. package/crates/team-agent/src/mcp_server/tools.rs +18 -4
  40. package/crates/team-agent/src/mcp_server/types.rs +3 -0
  41. package/crates/team-agent/src/mcp_server/wire.rs +30 -7
  42. package/crates/team-agent/src/messaging/leader_channel.rs +32 -5
  43. package/crates/team-agent/src/messaging/leader_receiver.rs +68 -21
  44. package/crates/team-agent/src/messaging/tests/runtime.rs +34 -23
  45. package/crates/team-agent/src/provider/adapter.rs +54 -13
  46. package/crates/team-agent/src/provider/adapters/claude_fork.rs +122 -0
  47. package/crates/team-agent/src/provider/adapters/copilot_fork.rs +306 -0
  48. package/crates/team-agent/src/provider/adapters/mod.rs +2 -0
  49. package/crates/team-agent/src/provider/session/capture.rs +321 -52
  50. package/crates/team-agent/src/provider/session/context_fork.rs +499 -0
  51. package/crates/team-agent/src/provider/session/mod.rs +6 -0
  52. package/crates/team-agent/src/provider/session_scan/claude.rs +1 -1
  53. package/crates/team-agent/src/provider/session_scan/codex.rs +144 -27
  54. package/crates/team-agent/src/provider/session_scan/common.rs +39 -6
  55. package/crates/team-agent/src/provider/session_scan/copilot.rs +47 -3
  56. package/crates/team-agent/src/provider/session_scan.rs +3 -3
  57. package/crates/team-agent/src/provider/tests/copilot_fork.rs +191 -0
  58. package/crates/team-agent/src/provider/tests.rs +1 -0
  59. package/crates/team-agent/src/tmux_backend/tests.rs +51 -16
  60. package/crates/team-agent/src/tmux_backend.rs +29 -2
  61. package/package.json +4 -4
  62. package/skills/team-agent/SKILL.md +8 -3
@@ -1,193 +1,269 @@
1
- #[test]
2
- fn mcp_tool_wire_names_and_parse_roundtrip() {
3
- let names = [
4
- (McpTool::AssignTask, "assign_task"),
5
- (McpTool::SendMessage, "send_message"),
6
- (McpTool::ReportResult, "report_result"),
7
- (McpTool::UpdateState, "update_state"),
8
- (McpTool::GetTeamStatus, "get_team_status"),
9
- (McpTool::StopAgent, "stop_agent"),
10
- (McpTool::ResetAgent, "reset_agent"),
11
- (McpTool::AddAgent, "add_agent"),
12
- (McpTool::ForkAgent, "fork_agent"),
13
- (McpTool::RequestHuman, "request_human"),
14
- (McpTool::StuckList, "stuck_list"),
15
- (McpTool::StuckCancel, "stuck_cancel"),
16
- ];
17
- for (tool, name) in names {
18
- assert_eq!(tool.wire_name(), name);
19
- assert_eq!(McpTool::parse(name), Some(tool));
20
- }
21
- // unknown → None (server.py:43 maps to UnknownTool)
22
- assert_eq!(McpTool::parse("nope"), None);
23
- assert_eq!(McpTool::parse("AssignTask"), None); // case-sensitive snake_case
1
+ #[test]
2
+ fn mcp_tool_wire_names_and_parse_roundtrip() {
3
+ let names = [
4
+ (McpTool::AssignTask, "assign_task"),
5
+ (McpTool::SendMessage, "send_message"),
6
+ (McpTool::ReportResult, "report_result"),
7
+ (McpTool::UpdateState, "update_state"),
8
+ (McpTool::GetTeamStatus, "get_team_status"),
9
+ (McpTool::StopAgent, "stop_agent"),
10
+ (McpTool::ResetAgent, "reset_agent"),
11
+ (McpTool::AddAgent, "add_agent"),
12
+ (McpTool::CloneAgent, "clone_agent"),
13
+ (McpTool::ForkAgent, "fork_agent"),
14
+ (McpTool::RequestHuman, "request_human"),
15
+ (McpTool::StuckList, "stuck_list"),
16
+ (McpTool::StuckCancel, "stuck_cancel"),
17
+ ];
18
+ for (tool, name) in names {
19
+ assert_eq!(tool.wire_name(), name);
20
+ assert_eq!(McpTool::parse(name), Some(tool));
24
21
  }
22
+ // unknown → None (server.py:43 maps to UnknownTool)
23
+ assert_eq!(McpTool::parse("nope"), None);
24
+ assert_eq!(McpTool::parse("AssignTask"), None); // case-sensitive snake_case
25
+ }
25
26
 
26
- #[test]
27
- fn rpc_method_classify() {
28
- assert_eq!(RpcMethod::classify("initialize"), RpcMethod::Initialize);
29
- assert_eq!(RpcMethod::classify("tools/list"), RpcMethod::ToolsList);
30
- assert_eq!(RpcMethod::classify("tools/call"), RpcMethod::ToolsCall);
31
- // notifications/* → Notification (no reply path)
32
- assert!(matches!(
33
- RpcMethod::classify("notifications/initialized"),
34
- RpcMethod::Notification(_)
35
- ));
36
- // unknown → Unknown
37
- assert_eq!(
38
- RpcMethod::classify("foo/bar"),
39
- RpcMethod::Unknown("foo/bar".to_string())
27
+ #[test]
28
+ fn rpc_method_classify() {
29
+ assert_eq!(RpcMethod::classify("initialize"), RpcMethod::Initialize);
30
+ assert_eq!(RpcMethod::classify("tools/list"), RpcMethod::ToolsList);
31
+ assert_eq!(RpcMethod::classify("tools/call"), RpcMethod::ToolsCall);
32
+ // notifications/* → Notification (no reply path)
33
+ assert!(matches!(
34
+ RpcMethod::classify("notifications/initialized"),
35
+ RpcMethod::Notification(_)
36
+ ));
37
+ // unknown → Unknown
38
+ assert_eq!(
39
+ RpcMethod::classify("foo/bar"),
40
+ RpcMethod::Unknown("foo/bar".to_string())
41
+ );
42
+ }
43
+
44
+ // ════════════════════════════════════════════════════════════════════════
45
+ // tools_contract — TOOLS wire list, exact names+order
46
+ // ════════════════════════════════════════════════════════════════════════
47
+ #[test]
48
+ fn tools_contract_has_thirteen_tools_in_order() {
49
+ let tools = tools_contract();
50
+ assert_eq!(tools.len(), 13);
51
+ let got: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
52
+ assert_eq!(
53
+ got,
54
+ vec![
55
+ "assign_task",
56
+ "send_message",
57
+ "report_result",
58
+ "update_state",
59
+ "get_team_status",
60
+ "stop_agent",
61
+ "reset_agent",
62
+ "add_agent",
63
+ "clone_agent",
64
+ "fork_agent",
65
+ "request_human",
66
+ "stuck_list",
67
+ "stuck_cancel",
68
+ ]
69
+ );
70
+ // each carries description + inputSchema
71
+ for t in &tools {
72
+ assert!(t.get("description").and_then(Value::as_str).is_some());
73
+ assert!(t.get("inputSchema").is_some());
74
+ }
75
+ // spot-check byte-stable description + schema for send_message
76
+ let send = tools
77
+ .iter()
78
+ .find(|t| t["name"] == json!("send_message"))
79
+ .unwrap();
80
+ assert_eq!(
81
+ send["description"],
82
+ json!("Send a message to a teammate, the leader, or '*' for all other team members. Provide only target and content; Team Agent fills sender, task id, ack policy, and delivery metadata.")
83
+ );
84
+ assert_eq!(send["inputSchema"]["additionalProperties"], json!(false));
85
+ assert_eq!(send["inputSchema"]["required"], json!(["to", "content"]));
86
+ for internal in ["sender", "task_id", "requires_ack"] {
87
+ assert!(
88
+ send["inputSchema"]["properties"].get(internal).is_none(),
89
+ "{internal} is framework-owned, not caller-supplied"
40
90
  );
41
91
  }
92
+ let clone = tools
93
+ .iter()
94
+ .find(|tool| tool["name"] == json!("clone_agent"))
95
+ .unwrap();
96
+ assert_eq!(
97
+ clone["description"],
98
+ json!("Clone a worker role into a fresh provider session.")
99
+ );
100
+ assert_eq!(clone["inputSchema"]["additionalProperties"], json!(false));
101
+ assert_eq!(
102
+ clone["inputSchema"]["required"],
103
+ json!(["source_agent_id", "as_agent_id"])
104
+ );
105
+ assert_eq!(
106
+ clone["inputSchema"]["properties"]
107
+ .as_object()
108
+ .unwrap()
109
+ .keys()
110
+ .cloned()
111
+ .collect::<BTreeSet<_>>(),
112
+ BTreeSet::from([
113
+ "as_agent_id".to_string(),
114
+ "label".to_string(),
115
+ "source_agent_id".to_string(),
116
+ ])
117
+ );
118
+ }
42
119
 
43
- // ════════════════════════════════════════════════════════════════════════
44
- // tools_contract — TOOLS wire list (contracts.py): 12 tools, exact names+order
45
- // ════════════════════════════════════════════════════════════════════════
46
- #[test]
47
- fn tools_contract_has_twelve_tools_in_order() {
48
- let tools = tools_contract();
49
- assert_eq!(tools.len(), 12);
50
- let got: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
51
- assert_eq!(got, vec![
52
- "assign_task", "send_message", "report_result", "update_state",
53
- "get_team_status", "stop_agent", "reset_agent", "add_agent",
54
- "fork_agent", "request_human", "stuck_list", "stuck_cancel",
55
- ]);
56
- // each carries description + inputSchema
57
- for t in &tools {
58
- assert!(t.get("description").and_then(Value::as_str).is_some());
59
- assert!(t.get("inputSchema").is_some());
60
- }
61
- // spot-check byte-stable description + schema for send_message
62
- let send = tools.iter().find(|t| t["name"] == json!("send_message")).unwrap();
120
+ #[test]
121
+ fn tools_contract_input_schemas_are_openai_strict_top_level_objects() {
122
+ let forbidden = ["oneOf", "anyOf", "allOf", "enum", "not"];
123
+ for tool in tools_contract() {
124
+ let schema = tool["inputSchema"].as_object().unwrap();
63
125
  assert_eq!(
64
- send["description"],
65
- json!("Send a message to a teammate, the leader, or '*' for all other team members. Provide only target and content; Team Agent fills sender, task id, ack policy, and delivery metadata.")
126
+ schema.get("type"),
127
+ Some(&json!("object")),
128
+ "schema must be a top-level object: {tool}"
66
129
  );
67
- assert_eq!(send["inputSchema"]["additionalProperties"], json!(false));
68
- assert_eq!(send["inputSchema"]["required"], json!(["to", "content"]));
69
- for internal in ["sender", "task_id", "requires_ack"] {
130
+ for key in forbidden {
70
131
  assert!(
71
- send["inputSchema"]["properties"].get(internal).is_none(),
72
- "{internal} is framework-owned, not caller-supplied"
132
+ !schema.contains_key(key),
133
+ "OpenAI rejects top-level `{key}` in MCP tool schema: {tool}"
73
134
  );
74
135
  }
75
- }
76
-
77
- #[test]
78
- fn tools_contract_input_schemas_are_openai_strict_top_level_objects() {
79
- let forbidden = ["oneOf", "anyOf", "allOf", "enum", "not"];
80
- for tool in tools_contract() {
81
- let schema = tool["inputSchema"].as_object().unwrap();
82
- assert_eq!(schema.get("type"), Some(&json!("object")), "schema must be a top-level object: {tool}");
83
- for key in forbidden {
84
- assert!(
85
- !schema.contains_key(key),
86
- "OpenAI rejects top-level `{key}` in MCP tool schema: {tool}"
87
- );
88
- }
89
- let properties = schema
90
- .get("properties")
91
- .and_then(Value::as_object)
92
- .unwrap_or_else(|| panic!("schema properties must be an object: {tool}"));
93
- for required in schema.get("required").and_then(Value::as_array).into_iter().flatten() {
94
- let Some(name) = required.as_str() else {
95
- panic!("required entries must be strings: {tool}");
96
- };
97
- assert!(
98
- properties.contains_key(name),
99
- "required property `{name}` must be declared in properties: {tool}"
100
- );
101
- }
136
+ let properties = schema
137
+ .get("properties")
138
+ .and_then(Value::as_object)
139
+ .unwrap_or_else(|| panic!("schema properties must be an object: {tool}"));
140
+ for required in schema
141
+ .get("required")
142
+ .and_then(Value::as_array)
143
+ .into_iter()
144
+ .flatten()
145
+ {
146
+ let Some(name) = required.as_str() else {
147
+ panic!("required entries must be strings: {tool}");
148
+ };
149
+ assert!(
150
+ properties.contains_key(name),
151
+ "required property `{name}` must be declared in properties: {tool}"
152
+ );
102
153
  }
103
154
  }
155
+ }
104
156
 
105
- // ════════════════════════════════════════════════════════════════════════
106
- // handle_mcp — JSON-RPC routing (server.py:46-91)
107
- // ════════════════════════════════════════════════════════════════════════
108
- #[test]
109
- fn handle_mcp_initialize_echoes_protocol_and_serverinfo() {
110
- let tools = TeamOrchestratorTools::with_identity(Path::new("/tmp/ws"), None, None);
111
- let resp = handle_mcp(&tools, &json!({
157
+ // ════════════════════════════════════════════════════════════════════════
158
+ // handle_mcp — JSON-RPC routing (server.py:46-91)
159
+ // ════════════════════════════════════════════════════════════════════════
160
+ #[test]
161
+ fn handle_mcp_initialize_echoes_protocol_and_serverinfo() {
162
+ let tools = TeamOrchestratorTools::with_identity(Path::new("/tmp/ws"), None, None);
163
+ let resp = handle_mcp(
164
+ &tools,
165
+ &json!({
112
166
  "jsonrpc": "2.0", "id": 1, "method": "initialize",
113
167
  "params": {"protocolVersion": "X"}
114
- })).unwrap().expect("initialize yields a frame");
115
- assert_eq!(resp.jsonrpc, "2.0");
116
- assert_eq!(resp.id, RpcId::Int(1));
117
- let result = resp.result.unwrap();
118
- assert_eq!(result["protocolVersion"], json!("X"));
119
- assert_eq!(result["serverInfo"]["name"], json!("team_orchestrator"));
120
- assert_eq!(result["serverInfo"]["version"], json!("0.1.4"));
121
- assert_eq!(result["capabilities"], json!({"tools": {}}));
122
- }
168
+ }),
169
+ )
170
+ .unwrap()
171
+ .expect("initialize yields a frame");
172
+ assert_eq!(resp.jsonrpc, "2.0");
173
+ assert_eq!(resp.id, RpcId::Int(1));
174
+ let result = resp.result.unwrap();
175
+ assert_eq!(result["protocolVersion"], json!("X"));
176
+ assert_eq!(result["serverInfo"]["name"], json!("team_orchestrator"));
177
+ assert_eq!(result["serverInfo"]["version"], json!("0.1.4"));
178
+ assert_eq!(result["capabilities"], json!({"tools": {}}));
179
+ }
123
180
 
124
- #[test]
125
- fn handle_mcp_initialize_defaults_protocol_version() {
126
- let tools = TeamOrchestratorTools::with_identity(Path::new("/tmp/ws"), None, None);
127
- let resp = handle_mcp(&tools, &json!({
181
+ #[test]
182
+ fn handle_mcp_initialize_defaults_protocol_version() {
183
+ let tools = TeamOrchestratorTools::with_identity(Path::new("/tmp/ws"), None, None);
184
+ let resp = handle_mcp(
185
+ &tools,
186
+ &json!({
128
187
  "jsonrpc": "2.0", "id": "abc", "method": "initialize"
129
- })).unwrap().unwrap();
130
- assert_eq!(resp.id, RpcId::Str("abc".to_string()));
131
- assert_eq!(resp.result.unwrap()["protocolVersion"], json!("2024-11-05"));
132
- }
188
+ }),
189
+ )
190
+ .unwrap()
191
+ .unwrap();
192
+ assert_eq!(resp.id, RpcId::Str("abc".to_string()));
193
+ assert_eq!(resp.result.unwrap()["protocolVersion"], json!("2024-11-05"));
194
+ }
133
195
 
134
- #[test]
135
- fn handle_mcp_notifications_return_none_no_frame() {
136
- // 铁律: notifications/* MUST NOT emit a frame (would corrupt stdout stream).
137
- let tools = TeamOrchestratorTools::with_identity(Path::new("/tmp/ws"), None, None);
138
- let resp = handle_mcp(&tools, &json!({
196
+ #[test]
197
+ fn handle_mcp_notifications_return_none_no_frame() {
198
+ // 铁律: notifications/* MUST NOT emit a frame (would corrupt stdout stream).
199
+ let tools = TeamOrchestratorTools::with_identity(Path::new("/tmp/ws"), None, None);
200
+ let resp = handle_mcp(
201
+ &tools,
202
+ &json!({
139
203
  "jsonrpc": "2.0", "method": "notifications/initialized"
140
- })).unwrap();
141
- assert!(resp.is_none(), "notifications/* → None (loop continues)");
142
- }
204
+ }),
205
+ )
206
+ .unwrap();
207
+ assert!(resp.is_none(), "notifications/* → None (loop continues)");
208
+ }
143
209
 
144
- #[test]
145
- fn handle_mcp_unknown_method_is_minus_32601() {
146
- let tools = TeamOrchestratorTools::with_identity(Path::new("/tmp/ws"), None, None);
147
- let resp = handle_mcp(&tools, &json!({
210
+ #[test]
211
+ fn handle_mcp_unknown_method_is_minus_32601() {
212
+ let tools = TeamOrchestratorTools::with_identity(Path::new("/tmp/ws"), None, None);
213
+ let resp = handle_mcp(
214
+ &tools,
215
+ &json!({
148
216
  "jsonrpc": "2.0", "id": 7, "method": "foo/bar"
149
- })).unwrap().unwrap();
150
- assert!(resp.result.is_none());
151
- let err = resp.error.unwrap();
152
- assert_eq!(err.code, -32601);
153
- assert_eq!(err.message, "unknown method 'foo/bar'"); // exact Python repr w/ quotes
154
- }
217
+ }),
218
+ )
219
+ .unwrap()
220
+ .unwrap();
221
+ assert!(resp.result.is_none());
222
+ let err = resp.error.unwrap();
223
+ assert_eq!(err.code, -32601);
224
+ assert_eq!(err.message, "unknown method 'foo/bar'"); // exact Python repr w/ quotes
225
+ }
155
226
 
156
- #[test]
157
- fn handle_mcp_unknown_tool_call_is_error_with_envelope_text() {
158
- // tools/call with unknown tool → isError:true, content[0].text == json.dumps(envelope)
159
- let tools = TeamOrchestratorTools::with_identity(Path::new("/tmp/ws"), None, None);
160
- let resp = handle_mcp(&tools, &json!({
227
+ #[test]
228
+ fn handle_mcp_unknown_tool_call_is_error_with_envelope_text() {
229
+ // tools/call with unknown tool → isError:true, content[0].text == json.dumps(envelope)
230
+ let tools = TeamOrchestratorTools::with_identity(Path::new("/tmp/ws"), None, None);
231
+ let resp = handle_mcp(
232
+ &tools,
233
+ &json!({
161
234
  "jsonrpc": "2.0", "id": 9, "method": "tools/call",
162
235
  "params": {"name": "nope", "arguments": {}}
163
- })).unwrap().unwrap();
164
- let result = resp.result.unwrap();
165
- assert_eq!(result["isError"], json!(true));
166
- let text = result["content"][0]["text"].as_str().unwrap();
167
- // the text is a JSON-encoded error envelope with redundant keys
168
- let env: Value = serde_json::from_str(text).unwrap();
169
- assert_eq!(env["ok"], json!(false));
170
- assert_eq!(env["reason"], json!("unknown_tool"));
171
- assert_eq!(env["error_code"], json!("unknown_tool"));
172
- assert_eq!(env["exc_type"], json!("UnknownTool"));
173
- assert_eq!(env["message"], json!("unknown tool 'nope'"));
174
- assert_eq!(env["error"], json!("unknown tool 'nope'"));
175
- assert_eq!(result["content"][0]["type"], json!("text"));
176
- }
236
+ }),
237
+ )
238
+ .unwrap()
239
+ .unwrap();
240
+ let result = resp.result.unwrap();
241
+ assert_eq!(result["isError"], json!(true));
242
+ let text = result["content"][0]["text"].as_str().unwrap();
243
+ // the text is a JSON-encoded error envelope with redundant keys
244
+ let env: Value = serde_json::from_str(text).unwrap();
245
+ assert_eq!(env["ok"], json!(false));
246
+ assert_eq!(env["reason"], json!("unknown_tool"));
247
+ assert_eq!(env["error_code"], json!("unknown_tool"));
248
+ assert_eq!(env["exc_type"], json!("UnknownTool"));
249
+ assert_eq!(env["message"], json!("unknown tool 'nope'"));
250
+ assert_eq!(env["error"], json!("unknown tool 'nope'"));
251
+ assert_eq!(result["content"][0]["type"], json!("text"));
252
+ }
177
253
 
178
- // ════════════════════════════════════════════════════════════════════════
179
- // dispatch — unknown tool → Err(UnknownTool) (server.py:43)
180
- // ════════════════════════════════════════════════════════════════════════
181
- #[test]
182
- fn dispatch_unknown_tool_returns_unknown_tool_error() {
183
- let tools = TeamOrchestratorTools::with_identity(Path::new("/tmp/ws"), None, None);
184
- let r = dispatch(&tools, &json!({"tool": "nope"}));
185
- let err = r.expect_err("unknown tool ⇒ Err");
186
- assert_eq!(err.reason, ToolErrorReason::UnknownTool);
187
- assert_eq!(err.exc_type, "UnknownTool");
188
- assert_eq!(err.message, "unknown tool 'nope'");
189
- }
254
+ // ════════════════════════════════════════════════════════════════════════
255
+ // dispatch — unknown tool → Err(UnknownTool) (server.py:43)
256
+ // ════════════════════════════════════════════════════════════════════════
257
+ #[test]
258
+ fn dispatch_unknown_tool_returns_unknown_tool_error() {
259
+ let tools = TeamOrchestratorTools::with_identity(Path::new("/tmp/ws"), None, None);
260
+ let r = dispatch(&tools, &json!({"tool": "nope"}));
261
+ let err = r.expect_err("unknown tool ⇒ Err");
262
+ assert_eq!(err.reason, ToolErrorReason::UnknownTool);
263
+ assert_eq!(err.exc_type, "UnknownTool");
264
+ assert_eq!(err.message, "unknown tool 'nope'");
265
+ }
190
266
 
191
- // ════════════════════════════════════════════════════════════════════════
192
- // requires_ack_for_target — leader-only → false (tools.py:16)
193
- // ════════════════════════════════════════════════════════════════════════
267
+ // ════════════════════════════════════════════════════════════════════════
268
+ // requires_ack_for_target — leader-only → false (tools.py:16)
269
+ // ════════════════════════════════════════════════════════════════════════
@@ -139,7 +139,7 @@ impl TeamOrchestratorTools {
139
139
  reconcile_assigned_task(latest, latest_team_key.as_deref(), &task_value);
140
140
  },
141
141
  )
142
- .map_err(tool_runtime_error)?;
142
+ .map_err(tool_runtime_error)?;
143
143
 
144
144
  let content = assignment_message(task, message);
145
145
  let out = self.send_message(
@@ -572,6 +572,22 @@ impl TeamOrchestratorTools {
572
572
  )
573
573
  }
574
574
 
575
+ pub fn clone_agent(
576
+ &self,
577
+ source_agent_id: &str,
578
+ as_agent_id: &str,
579
+ label: Option<&str>,
580
+ ) -> ToolResult {
581
+ let owner_team = self.canonical_owner_team_key()?;
582
+ super::lifecycle_tools::clone_agent(
583
+ &self.workspace,
584
+ owner_team.as_ref(),
585
+ source_agent_id,
586
+ as_agent_id,
587
+ label,
588
+ )
589
+ }
590
+
575
591
  /// `request_human` (`tools.py:342-346`): create a `requires_ack` leader message via
576
592
  /// the shared leader-delivery funnel; sender = env / inferred / `"unknown"`.
577
593
  /// Returns `{ok:true, message_id, status:"needs_human"}`.
@@ -797,9 +813,7 @@ impl TeamOrchestratorTools {
797
813
  .collect();
798
814
  let best = ranked.first().cloned();
799
815
  let hint = if let Some(best) = best.as_deref() {
800
- format!(
801
- "the requested peer is not in your owner team; did you mean `{best}`?"
802
- )
816
+ format!("the requested peer is not in your owner team; did you mean `{best}`?")
803
817
  } else {
804
818
  "the requested peer is not part of your team; worker-origin MCP cannot widen team scope.".to_string()
805
819
  };
@@ -63,6 +63,7 @@ pub enum McpTool {
63
63
  StopAgent,
64
64
  ResetAgent,
65
65
  AddAgent,
66
+ CloneAgent,
66
67
  ForkAgent,
67
68
  RequestHuman,
68
69
  StuckList,
@@ -82,6 +83,7 @@ impl McpTool {
82
83
  McpTool::StopAgent => "stop_agent",
83
84
  McpTool::ResetAgent => "reset_agent",
84
85
  McpTool::AddAgent => "add_agent",
86
+ McpTool::CloneAgent => "clone_agent",
85
87
  McpTool::ForkAgent => "fork_agent",
86
88
  McpTool::RequestHuman => "request_human",
87
89
  McpTool::StuckList => "stuck_list",
@@ -101,6 +103,7 @@ impl McpTool {
101
103
  "stop_agent" => Some(McpTool::StopAgent),
102
104
  "reset_agent" => Some(McpTool::ResetAgent),
103
105
  "add_agent" => Some(McpTool::AddAgent),
106
+ "clone_agent" => Some(McpTool::CloneAgent),
104
107
  "fork_agent" => Some(McpTool::ForkAgent),
105
108
  "request_human" => Some(McpTool::RequestHuman),
106
109
  "stuck_list" => Some(McpTool::StuckList),
@@ -33,6 +33,7 @@ pub fn tools_contract() -> Vec<Value> {
33
33
  McpTool::StopAgent,
34
34
  McpTool::ResetAgent,
35
35
  McpTool::AddAgent,
36
+ McpTool::CloneAgent,
36
37
  McpTool::ForkAgent,
37
38
  McpTool::RequestHuman,
38
39
  McpTool::StuckList,
@@ -378,6 +379,7 @@ fn tool_contract(tool: McpTool) -> Value {
378
379
  McpTool::StopAgent => ("Stop a running worker.", vec!["agent_id"]),
379
380
  McpTool::ResetAgent => ("Reset one worker to a fresh session.", vec!["agent_id", "discard_session"]),
380
381
  McpTool::AddAgent => ("Add a first-class worker from a role file.", vec!["new_agent_id", "role_file_path"]),
382
+ McpTool::CloneAgent => ("Clone a worker role into a fresh provider session.", vec!["source_agent_id", "as_agent_id"]),
381
383
  McpTool::ForkAgent => ("Fork a running worker.", vec!["source_agent_id", "as_agent_id"]),
382
384
  McpTool::RequestHuman => ("Ask the leader or user for human input.", vec!["question"]),
383
385
  McpTool::StuckList => ("List manually suppressed idle alerts.", Vec::new()),
@@ -522,6 +524,23 @@ fn tool_properties(tool: McpTool) -> serde_json::Map<String, Value> {
522
524
  string_property("Optional display label."),
523
525
  );
524
526
  }
527
+ McpTool::CloneAgent => {
528
+ insert_property(
529
+ &mut properties,
530
+ "source_agent_id",
531
+ string_property("Agent id to clone from."),
532
+ );
533
+ insert_property(
534
+ &mut properties,
535
+ "as_agent_id",
536
+ string_property("Agent id for the cloned worker."),
537
+ );
538
+ insert_property(
539
+ &mut properties,
540
+ "label",
541
+ string_property("Optional display label."),
542
+ );
543
+ }
525
544
  McpTool::RequestHuman => {
526
545
  insert_property(
527
546
  &mut properties,
@@ -591,13 +610,7 @@ pub(crate) fn dispatch_tool(
591
610
  McpTool::SendMessage => {
592
611
  let target = message_target_from_value(args.get("to"));
593
612
  let content = args.get("content").and_then(Value::as_str).unwrap_or("");
594
- let outcome = tools.send_message(
595
- &target,
596
- content,
597
- None,
598
- None,
599
- None,
600
- )?;
613
+ let outcome = tools.send_message(&target, content, None, None, None)?;
601
614
  match outcome {
602
615
  SendOutcome::WorkerAccepted { .. } => Ok(ToolOk {
603
616
  fields: object_fields(outcome.to_value()),
@@ -668,6 +681,15 @@ pub(crate) fn dispatch_tool(
668
681
  .unwrap_or(""),
669
682
  args.get("label").and_then(Value::as_str),
670
683
  ),
684
+ McpTool::CloneAgent => tools.clone_agent(
685
+ args.get("source_agent_id")
686
+ .and_then(Value::as_str)
687
+ .unwrap_or(""),
688
+ args.get("as_agent_id")
689
+ .and_then(Value::as_str)
690
+ .unwrap_or(""),
691
+ args.get("label").and_then(Value::as_str),
692
+ ),
671
693
  McpTool::RequestHuman => tools.request_human(
672
694
  args.get("question").and_then(Value::as_str).unwrap_or(""),
673
695
  args.get("task_id").and_then(Value::as_str),
@@ -695,6 +717,7 @@ fn scope_ceiling_tool(tool: McpTool) -> bool {
695
717
  | McpTool::GetTeamStatus
696
718
  | McpTool::StopAgent
697
719
  | McpTool::ResetAgent
720
+ | McpTool::CloneAgent
698
721
  | McpTool::ForkAgent
699
722
  )
700
723
  }
@@ -101,6 +101,7 @@ pub fn resolve_live_leader_channel(
101
101
  .current_path
102
102
  .as_deref()
103
103
  .is_some_and(|path| !path_is_in_workspace(path, workspace))
104
+ && !explicit_claim_authority_matches(workspace, receiver, &observed)
104
105
  {
105
106
  return LeaderChannelResolution::Unbound(LeaderChannelUnbound::PaneWorkspaceMismatch);
106
107
  }
@@ -113,14 +114,40 @@ pub fn resolve_live_leader_channel(
113
114
  }))
114
115
  }
115
116
 
116
- fn path_is_in_workspace(path: &Path, workspace: &Path) -> bool {
117
- let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
118
- let workspace = workspace
119
- .canonicalize()
120
- .unwrap_or_else(|_| workspace.to_path_buf());
117
+ fn explicit_claim_authority_matches(
118
+ workspace: &Path,
119
+ receiver: &Value,
120
+ observed: &PaneInfo,
121
+ ) -> bool {
122
+ if string_field(receiver, "scope_authority") != Some("explicit_claim") {
123
+ return false;
124
+ }
125
+ let Some(authorized_workspace) = string_field(receiver, "authorized_team_workspace") else {
126
+ return false;
127
+ };
128
+ if canonical_path(Path::new(authorized_workspace)) != canonical_path(workspace) {
129
+ return false;
130
+ }
131
+ let Some(recorded_nonce) = string_field(receiver, "binding_nonce") else {
132
+ return false;
133
+ };
134
+ observed
135
+ .leader_env
136
+ .get(crate::tmux_backend::PANE_BINDING_NONCE_METADATA_KEY)
137
+ .filter(|nonce| !nonce.is_empty())
138
+ .is_some_and(|live_nonce| live_nonce == recorded_nonce)
139
+ }
140
+
141
+ pub(crate) fn path_is_in_workspace(path: &Path, workspace: &Path) -> bool {
142
+ let path = canonical_path(path);
143
+ let workspace = canonical_path(workspace);
121
144
  path == workspace || path.starts_with(workspace)
122
145
  }
123
146
 
147
+ fn canonical_path(path: &Path) -> std::path::PathBuf {
148
+ path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
149
+ }
150
+
124
151
  fn receiver_transport_conflicts(receiver: &Value) -> bool {
125
152
  let mode = receiver.get("mode").and_then(Value::as_str);
126
153
  let transport_kind = receiver.get("transport_kind").and_then(Value::as_str);