@team-agent/installer 0.3.3 → 0.3.4

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.
@@ -40,6 +40,7 @@ pub(crate) mod profile_launch;
40
40
  pub(crate) mod profile_smoke;
41
41
  pub mod restart;
42
42
  pub mod types;
43
+ pub(crate) mod worker_command_context;
43
44
 
44
45
  use std::collections::BTreeMap;
45
46
  use std::path::PathBuf;
@@ -451,10 +451,21 @@ fn write_start_agent_start_event(
451
451
  let adapter = crate::provider::get_adapter(provider);
452
452
  // Contract C / F6.4: event log must record the same context-aware argv that the
453
453
  // actual spawn used — so the role/tools/MCP context appears in `start_agent.agent_start`.
454
- let role = agent.get("role").and_then(|v| v.as_str());
455
454
  let safety = crate::lifecycle::launch::effective_runtime_config_for_worker_spawn()?;
456
- let tools = crate::lifecycle::launch::worker_tool_refs(agent_tool_strings(agent), &safety);
457
- let tool_refs: Vec<&str> = tools.iter().map(String::as_str).collect();
455
+ let command_agent =
456
+ crate::lifecycle::worker_command_context::WorkerCommandAgent::from_json(
457
+ agent,
458
+ Some(agent_id.as_str()),
459
+ provider,
460
+ );
461
+ let system_prompt =
462
+ crate::lifecycle::worker_command_context::compile_worker_system_prompt(&command_agent)?;
463
+ let tools = crate::lifecycle::worker_command_context::resolved_tool_strings_for_command(
464
+ &command_agent,
465
+ provider,
466
+ &safety,
467
+ )?;
468
+ let resolved_tool_refs: Vec<&str> = tools.iter().map(String::as_str).collect();
458
469
  let mcp_config = adapter
459
470
  .mcp_config(auth_mode)
460
471
  .map_err(|e| LifecycleError::Provider(e.to_string()))?;
@@ -484,9 +495,9 @@ fn write_start_agent_start_event(
484
495
  let context = crate::provider::ProviderCommandContext {
485
496
  auth_mode,
486
497
  mcp_config: Some(&mcp_config),
487
- system_prompt: role,
498
+ system_prompt: Some(system_prompt.as_str()),
488
499
  model: command_model,
489
- tools: &tool_refs,
500
+ tools: &resolved_tool_refs,
490
501
  profile_launch: Some(&profile_launch),
491
502
  };
492
503
  let mut plan = match session_id {
@@ -29,7 +29,6 @@ pub(super) fn spawn_agent_window(
29
29
  // Contract C / F6.4: thread compiled role/tools/MCP context through restart as well —
30
30
  // a restarted worker must come back up with the SAME callable MCP capability + role
31
31
  // prompt as a fresh launch, else `report_result` becomes unreachable after every restart.
32
- let role = agent.get("role").and_then(|v| v.as_str());
33
32
  let detected_safety;
34
33
  let safety = if let Some(safety) = safety {
35
34
  safety
@@ -37,8 +36,20 @@ pub(super) fn spawn_agent_window(
37
36
  detected_safety = crate::lifecycle::launch::effective_runtime_config_for_worker_spawn()?;
38
37
  &detected_safety
39
38
  };
40
- let tools = crate::lifecycle::launch::worker_tool_refs(agent_tool_strings(agent), safety);
41
- let tool_refs: Vec<&str> = tools.iter().map(String::as_str).collect();
39
+ let command_agent =
40
+ crate::lifecycle::worker_command_context::WorkerCommandAgent::from_json(
41
+ agent,
42
+ Some(agent_id.as_str()),
43
+ provider,
44
+ );
45
+ let system_prompt =
46
+ crate::lifecycle::worker_command_context::compile_worker_system_prompt(&command_agent)?;
47
+ let tools = crate::lifecycle::worker_command_context::resolved_tool_strings_for_command(
48
+ &command_agent,
49
+ provider,
50
+ safety,
51
+ )?;
52
+ let resolved_tool_refs: Vec<&str> = tools.iter().map(String::as_str).collect();
42
53
  // owner_team_id resolution: prefer the runtime-state row's `owner_team_id` (set by
43
54
  // launch/restart); fall back to the active team key for paths that don't write the
44
55
  // row first (e.g. add-agent calls spawn before upserting team metadata).
@@ -78,9 +89,9 @@ pub(super) fn spawn_agent_window(
78
89
  let context = crate::provider::ProviderCommandContext {
79
90
  auth_mode,
80
91
  mcp_config: Some(&mcp_config),
81
- system_prompt: role,
92
+ system_prompt: Some(system_prompt.as_str()),
82
93
  model: command_model,
83
- tools: &tool_refs,
94
+ tools: &resolved_tool_refs,
84
95
  profile_launch: Some(&profile_launch),
85
96
  };
86
97
  let mut plan = match resume_session_id {
@@ -319,24 +330,6 @@ pub(crate) fn restart_required_missing_session_agent_ids(state: &serde_json::Val
319
330
  missing.sort();
320
331
  missing
321
332
  }
322
- /// Tools list off an agent's runtime state entry (`tools: [...]`). Restart paths
323
- /// don't have the full spec object, only the runtime state — so they read tools from
324
- /// the state row, falling back to an empty list. Contract C requires the worker
325
- /// command be built with the tool list, even on restart.
326
- pub(super) fn agent_tool_strings(agent: &serde_json::Value) -> Vec<String> {
327
- agent
328
- .get("tools")
329
- .and_then(|v| v.as_array())
330
- .map(|items| {
331
- items
332
- .iter()
333
- .filter_map(|v| v.as_str())
334
- .map(str::to_string)
335
- .collect()
336
- })
337
- .unwrap_or_default()
338
- }
339
-
340
333
  pub(super) fn agent_window(agent: &serde_json::Value, agent_id: &AgentId) -> String {
341
334
  agent
342
335
  .get("window")
@@ -1,6 +1,6 @@
1
- use super::*;
2
1
  use super::common::*;
3
2
  use super::selection::classify_restart_plan;
3
+ use super::*;
4
4
 
5
5
  // ── lifecycle::restart —— 整队 Route B resume-or-fresh 重建 ──────────────────
6
6
 
@@ -101,10 +101,9 @@ pub fn restart_with_transport_with_session_convergence_deadline(
101
101
  .map_err(|e| LifecycleError::TeamSelect(e.to_string()))?;
102
102
  let mut state = selected.state;
103
103
  crate::lifecycle::launch::ensure_owner_allowed_for_state(&state, None)?;
104
- let spec_workspace = selected
105
- .spec_workspace
106
- .as_ref()
107
- .ok_or_else(|| LifecycleError::TeamSelect("active team spec workspace not found".to_string()))?;
104
+ let spec_workspace = selected.spec_workspace.as_ref().ok_or_else(|| {
105
+ LifecycleError::TeamSelect("active team spec workspace not found".to_string())
106
+ })?;
108
107
  let spec = load_team_spec(spec_workspace)?;
109
108
  let safety = crate::lifecycle::launch::effective_runtime_config(&spec)?;
110
109
  let mut convergence = converge_missing_provider_sessions(
@@ -236,10 +235,20 @@ pub fn restart_with_transport_with_session_convergence_deadline(
236
235
  crate::state::projection::save_team_scoped_state(&selected.run_workspace, &state)
237
236
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
238
237
  let coordinator_started = start_coordinator_for_workspace(&selected.run_workspace)?;
238
+ let attach_commands = crate::tmux_backend::attach_commands_for_windows(
239
+ &selected.run_workspace,
240
+ &session_name,
241
+ plan.decisions
242
+ .iter()
243
+ .map(|decision| decision.agent_id.as_str()),
244
+ );
245
+ let next_actions = attach_commands.clone();
239
246
  Ok(RestartReport::Restarted {
240
247
  session_name,
241
248
  agents: plan.decisions,
242
249
  coordinator_started,
250
+ next_actions,
251
+ attach_commands,
243
252
  })
244
253
  }
245
254
 
@@ -456,10 +465,7 @@ fn mark_leader_receiver_rebind_required(state: &mut serde_json::Value, session_n
456
465
  .and_then(|v| v.as_str())
457
466
  .is_some_and(|status| status == "attached")
458
467
  {
459
- receiver.insert(
460
- "status".to_string(),
461
- serde_json::json!("rebind_required"),
462
- );
468
+ receiver.insert("status".to_string(), serde_json::json!("rebind_required"));
463
469
  }
464
470
  }
465
471
 
@@ -520,11 +526,7 @@ fn mark_agent_respawned(
520
526
  if let Some(pane_pid) = pane_pid {
521
527
  agent.insert("pane_pid".to_string(), serde_json::json!(pane_pid));
522
528
  }
523
- crate::lifecycle::launch::persist_command_plan_state(
524
- agent,
525
- &spawn.plan,
526
- &spawn.profile_launch,
527
- );
529
+ crate::lifecycle::launch::persist_command_plan_state(agent, &spawn.plan, &spawn.profile_launch);
528
530
  persist_effective_approval_policy_for_restart(agent, safety);
529
531
  agent.remove("startup_prompts");
530
532
  agent.remove("startup_prompt_status");
@@ -583,8 +585,7 @@ fn write_restart_resume_decision_event(
583
585
 
584
586
  let path = workspace.join(".team").join("logs").join("events.jsonl");
585
587
  if let Some(parent) = path.parent() {
586
- std::fs::create_dir_all(parent)
587
- .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
588
+ std::fs::create_dir_all(parent).map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
588
589
  }
589
590
  let mut event = serde_json::json!({
590
591
  "ts": chrono::Utc::now().to_rfc3339(),
@@ -615,8 +616,8 @@ fn write_restart_resume_decision_event(
615
616
  }
616
617
  }
617
618
  }
618
- let line = serde_json::to_string(&event)
619
- .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
619
+ let line =
620
+ serde_json::to_string(&event).map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
620
621
  let mut file = std::fs::OpenOptions::new()
621
622
  .create(true)
622
623
  .append(true)
@@ -667,7 +668,10 @@ pub fn select_restart_state(
667
668
  .get("active_team_key")
668
669
  .and_then(serde_json::Value::as_str)
669
670
  .filter(|s| !s.is_empty())
670
- .map_or_else(|| crate::state::projection::team_state_key(&selected), str::to_string);
671
+ .map_or_else(
672
+ || crate::state::projection::team_state_key(&selected),
673
+ str::to_string,
674
+ );
671
675
  Ok(restart_candidate_from_state(workspace, &key, &selected))
672
676
  }
673
677
 
@@ -733,12 +737,14 @@ fn restart_candidate_has_context(state: &serde_json::Value) -> bool {
733
737
  .and_then(serde_json::Value::as_object)
734
738
  .is_some_and(|agents| {
735
739
  agents.values().any(|agent| {
736
- ["session_id", "rollout_path", "first_send_at"].iter().any(|key| {
737
- agent
738
- .get(*key)
739
- .and_then(serde_json::Value::as_str)
740
- .is_some_and(|s| !s.is_empty())
741
- })
740
+ ["session_id", "rollout_path", "first_send_at"]
741
+ .iter()
742
+ .any(|key| {
743
+ agent
744
+ .get(*key)
745
+ .and_then(serde_json::Value::as_str)
746
+ .is_some_and(|s| !s.is_empty())
747
+ })
742
748
  })
743
749
  })
744
750
  }
@@ -185,14 +185,14 @@ fn plan_condition_rejects_out_of_grammar() {
185
185
 
186
186
  // ───────────────────────────────────────────────────────────────────────
187
187
  // resolve_display_backend — display/backend.py
188
- // 默认 adaptive;非默认非静默(non_default=true 触发 display.backend_resolved)。
188
+ // 默认 none;非默认非静默(non_default=true 触发 display.backend_resolved)。
189
189
  // ───────────────────────────────────────────────────────────────────────
190
190
 
191
191
  #[test]
192
- fn resolve_backend_defaults_to_adaptive_when_none_requested() {
192
+ fn resolve_backend_defaults_to_none_when_none_requested() {
193
193
  let r = resolve_display_backend(None, None);
194
- assert_eq!(r.backend, DisplayBackend::Adaptive);
195
- assert!(!r.non_default, "默认 adaptive 不应标记 non_default");
194
+ assert_eq!(r.backend, DisplayBackend::None);
195
+ assert!(!r.non_default, "默认 none 不应标记 non_default");
196
196
  }
197
197
 
198
198
  #[test]
@@ -886,8 +886,8 @@ fn quick_start_state_seeds_spec_path_workspace_leader_display_backend() {
886
886
  );
887
887
  assert_eq!(
888
888
  state["display_backend"],
889
- json!("adaptive"),
890
- "golden resolve_display_backend(None, source='launch') defaults to adaptive"
889
+ json!("none"),
890
+ "golden resolve_display_backend(None, source='launch') defaults to none"
891
891
  );
892
892
  }
893
893
 
@@ -483,6 +483,8 @@ pub enum QuickStartReport {
483
483
  session_name: SessionName,
484
484
  launch: Box<LaunchReport>,
485
485
  next_actions: Vec<String>,
486
+ attach_commands: Vec<String>,
487
+ display_backend: String,
486
488
  /// BUG-7: real readiness verdict. `Ready` ⇒ the wrapper completed AND the
487
489
  /// caller already verified tool-set availability; the framework itself
488
490
  /// never emits this without an external observable confirming worker
@@ -613,6 +615,8 @@ pub enum RestartReport {
613
615
  session_name: SessionName,
614
616
  agents: Vec<RestartedAgent>,
615
617
  coordinator_started: bool,
618
+ next_actions: Vec<String>,
619
+ attach_commands: Vec<String>,
616
620
  },
617
621
  /// atomic refusal(`reason=resume_atomicity`):某 interacted worker 不可 resume
618
622
  /// 且非 allow_fresh。**nothing created yet**,无需回滚。
@@ -0,0 +1,326 @@
1
+ use std::path::Path;
2
+
3
+ use crate::lifecycle::types::{DangerousApproval, LifecycleError};
4
+ use crate::model::enums::{Enforcement, Provider};
5
+ use crate::model::ids::AgentId;
6
+ use crate::model::permissions::{resolve_permissions, AgentPermissionInput};
7
+
8
+ const RUNTIME_CONTRACT_SECTION: &str = r#"# Team Agent Teammate Runtime Contract
9
+
10
+ You are a teammate in a Team Agent runtime, not the user's primary assistant.
11
+ The user normally talks to the team lead. Plain text you write in this worker
12
+ session is local to this session and is not a team message.
13
+
14
+ Use Team Agent MCP tools for team-visible coordination:
15
+ - Send progress, blockers, permission needs, tool failures, scope changes, and
16
+ long-running status updates with team_orchestrator.send_message(to='leader',
17
+ content='<short message>').
18
+ - Send to another teammate by agent id when coordination is useful, or use
19
+ to='*' to notify every other team member. The runtime resolves only this team
20
+ and excludes your own worker.
21
+ - When the task is complete, call team_orchestrator.report_result exactly once.
22
+ - Do not pass sender, task_id, agent_id, schema_version, or ack fields unless
23
+ doing a low-level compatibility diagnostic. The MCP runtime fills protocol
24
+ fields from the current worker and task state.
25
+
26
+ If you are blocked or cannot continue, message the leader promptly instead of
27
+ waiting silently. If work takes several minutes, send a short progress update.
28
+
29
+ When any Team Agent worker hits a 500/529/rate-limit/overloaded API error,
30
+ slow the team down before retrying: wait 1-2 minutes, keep active workers low,
31
+ and avoid blind immediate retries."#;
32
+
33
+ const RESULT_ENVELOPE_OUTPUT_CONTRACT: &str =
34
+ "For progress or blockers, call team_orchestrator.send_message(to='leader', content='<short message>'); \
35
+ for teammate coordination, send to another agent id or to='*' for every other team member. \
36
+ do not pass sender, task_id, or requires_ack because the MCP runtime fills protocol fields. \
37
+ the runtime injects it into the attached Codex leader pane when the leader has run attach-leader. \
38
+ If no leader is attached, the tool returns a fallback/failed result instead of completion. \
39
+ Final completion must call team_orchestrator.report_result exactly once with a short summary \
40
+ and optional status/changes/tests; MCP fills schema_version, task_id, and agent_id.";
41
+
42
+ pub(crate) struct WorkerCommandAgent {
43
+ id: Option<String>,
44
+ provider: Provider,
45
+ role: Option<String>,
46
+ declared_tools: Option<Vec<String>>,
47
+ system_prompt_inline: Option<String>,
48
+ system_prompt_file: Option<String>,
49
+ output_contract_format: Option<String>,
50
+ }
51
+
52
+ impl WorkerCommandAgent {
53
+ pub(crate) fn from_yaml(
54
+ agent: &crate::model::yaml::Value,
55
+ fallback_id: Option<&str>,
56
+ provider: Provider,
57
+ ) -> Self {
58
+ let system_prompt = agent.get("system_prompt");
59
+ Self {
60
+ id: agent
61
+ .get("id")
62
+ .and_then(crate::model::yaml::Value::as_str)
63
+ .or(fallback_id)
64
+ .map(str::to_string),
65
+ provider,
66
+ role: agent
67
+ .get("role")
68
+ .and_then(crate::model::yaml::Value::as_str)
69
+ .map(str::to_string),
70
+ declared_tools: agent
71
+ .get("tools")
72
+ .and_then(crate::model::yaml::Value::as_list)
73
+ .map(|items| {
74
+ items
75
+ .iter()
76
+ .filter_map(crate::model::yaml::Value::as_str)
77
+ .map(str::to_string)
78
+ .collect()
79
+ }),
80
+ system_prompt_inline: system_prompt
81
+ .and_then(|prompt| prompt.get("inline"))
82
+ .and_then(crate::model::yaml::Value::as_str)
83
+ .filter(|value| !value.is_empty())
84
+ .map(str::to_string),
85
+ system_prompt_file: system_prompt
86
+ .and_then(|prompt| prompt.get("file"))
87
+ .filter(|value| value.is_truthy())
88
+ .and_then(crate::model::yaml::Value::as_str)
89
+ .map(str::to_string),
90
+ output_contract_format: agent
91
+ .get("output_contract")
92
+ .and_then(|contract| contract.get("format"))
93
+ .and_then(crate::model::yaml::Value::as_str)
94
+ .map(str::to_string),
95
+ }
96
+ }
97
+
98
+ pub(crate) fn from_json(
99
+ agent: &serde_json::Value,
100
+ fallback_id: Option<&str>,
101
+ provider: Provider,
102
+ ) -> Self {
103
+ let system_prompt = agent.get("system_prompt");
104
+ Self {
105
+ id: agent
106
+ .get("id")
107
+ .and_then(serde_json::Value::as_str)
108
+ .or(fallback_id)
109
+ .map(str::to_string),
110
+ provider,
111
+ role: agent
112
+ .get("role")
113
+ .and_then(serde_json::Value::as_str)
114
+ .map(str::to_string),
115
+ declared_tools: agent
116
+ .get("tools")
117
+ .and_then(serde_json::Value::as_array)
118
+ .map(|items| {
119
+ items
120
+ .iter()
121
+ .filter_map(serde_json::Value::as_str)
122
+ .map(str::to_string)
123
+ .collect()
124
+ }),
125
+ system_prompt_inline: system_prompt
126
+ .and_then(|prompt| prompt.get("inline"))
127
+ .and_then(serde_json::Value::as_str)
128
+ .filter(|value| !value.is_empty())
129
+ .map(str::to_string),
130
+ system_prompt_file: system_prompt
131
+ .and_then(|prompt| prompt.get("file"))
132
+ .and_then(serde_json::Value::as_str)
133
+ .filter(|value| !value.is_empty())
134
+ .map(str::to_string),
135
+ output_contract_format: agent
136
+ .get("output_contract")
137
+ .and_then(|contract| contract.get("format"))
138
+ .and_then(serde_json::Value::as_str)
139
+ .map(str::to_string),
140
+ }
141
+ }
142
+ }
143
+
144
+ pub(crate) fn compile_worker_system_prompt(
145
+ agent: &WorkerCommandAgent,
146
+ ) -> Result<String, LifecycleError> {
147
+ let mut chunks = vec![
148
+ runtime_contract_section(),
149
+ identity_section(agent),
150
+ role_body(agent)?,
151
+ ];
152
+ if let Some(contract) = output_contract(agent) {
153
+ chunks.push(contract);
154
+ }
155
+ if let Some(notes) = permission_notes(agent)? {
156
+ chunks.push(notes);
157
+ }
158
+ Ok(chunks
159
+ .into_iter()
160
+ .filter(|chunk| !chunk.is_empty())
161
+ .collect::<Vec<_>>()
162
+ .join("\n\n"))
163
+ }
164
+
165
+ pub(crate) fn resolved_tool_strings_for_command(
166
+ agent: &WorkerCommandAgent,
167
+ provider: Provider,
168
+ safety: &DangerousApproval,
169
+ ) -> Result<Vec<String>, LifecycleError> {
170
+ let mut tools: Vec<String> = resolve_agent_permissions(agent, provider)?
171
+ .sorted_tool_strings()
172
+ .into_iter()
173
+ .map(str::to_string)
174
+ .collect();
175
+ if safety.enabled && !tools.iter().any(|tool| tool == "dangerous_auto_approve") {
176
+ tools.push("dangerous_auto_approve".to_string());
177
+ }
178
+ Ok(tools)
179
+ }
180
+
181
+ fn resolve_agent_permissions(
182
+ agent: &WorkerCommandAgent,
183
+ provider: Provider,
184
+ ) -> Result<crate::model::permissions::ResolvedPermissions, LifecycleError> {
185
+ resolve_permissions(&AgentPermissionInput {
186
+ id: agent.id.as_deref().map(AgentId::new),
187
+ provider,
188
+ role: agent.role.clone(),
189
+ tools: agent.declared_tools.clone(),
190
+ })
191
+ .map_err(|e| LifecycleError::Compile(e.to_string()))
192
+ }
193
+
194
+ fn runtime_contract_section() -> String {
195
+ RUNTIME_CONTRACT_SECTION.to_string()
196
+ }
197
+
198
+ fn identity_section(agent: &WorkerCommandAgent) -> String {
199
+ format!(
200
+ "You are Team Agent worker `{}` with role `{}`. When asked about your role or identity, answer with this Team Agent worker identity first, not only the generic provider product identity.",
201
+ agent.id.as_deref().unwrap_or("unknown"),
202
+ agent.role.as_deref().unwrap_or("developer")
203
+ )
204
+ }
205
+
206
+ fn role_body(agent: &WorkerCommandAgent) -> Result<String, LifecycleError> {
207
+ let mut chunks = Vec::new();
208
+ if let Some(inline) = &agent.system_prompt_inline {
209
+ chunks.push(inline.clone());
210
+ }
211
+ if let Some(path) = &agent.system_prompt_file {
212
+ let body = std::fs::read_to_string(Path::new(path))
213
+ .map_err(|e| LifecycleError::Compile(format!("read system_prompt.file {path}: {e}")))?;
214
+ if !body.is_empty() {
215
+ chunks.push(body);
216
+ }
217
+ }
218
+ Ok(chunks.join("\n\n"))
219
+ }
220
+
221
+ fn output_contract(agent: &WorkerCommandAgent) -> Option<String> {
222
+ (agent.output_contract_format.as_deref() == Some("result_envelope_v1"))
223
+ .then(|| RESULT_ENVELOPE_OUTPUT_CONTRACT.to_string())
224
+ }
225
+
226
+ fn permission_notes(agent: &WorkerCommandAgent) -> Result<Option<String>, LifecycleError> {
227
+ let permissions = resolve_agent_permissions(agent, agent.provider)?;
228
+ if !permissions.has_prompt_only {
229
+ return Ok(None);
230
+ }
231
+ let mut prompt_only: Vec<String> = permissions
232
+ .resolved_tools
233
+ .iter()
234
+ .filter(|tool| tool.enforcement == Enforcement::PromptOnly)
235
+ .filter_map(|tool| serde_json::to_value(tool.tool).ok())
236
+ .filter_map(|value| value.as_str().map(str::to_string))
237
+ .collect();
238
+ prompt_only.sort();
239
+ Ok(Some(format!(
240
+ "Permission note: these tools are prompt-only for this provider and not hard-enforced: {}",
241
+ prompt_only.join(", ")
242
+ )))
243
+ }
244
+
245
+ #[cfg(test)]
246
+ mod tests {
247
+ use super::*;
248
+ use crate::lifecycle::types::DangerousApprovalSource;
249
+
250
+ fn disabled_safety() -> DangerousApproval {
251
+ DangerousApproval {
252
+ enabled: false,
253
+ source: DangerousApprovalSource::Disabled,
254
+ inherited: false,
255
+ provider: None,
256
+ flag: None,
257
+ worker_capability_above_leader: false,
258
+ ancestry_binary_name: None,
259
+ unexpected_binary: false,
260
+ }
261
+ }
262
+
263
+ #[test]
264
+ fn empty_tools_use_role_defaults_and_aliases_resolve_before_command() {
265
+ let agent = WorkerCommandAgent {
266
+ id: Some("dev".to_string()),
267
+ provider: Provider::ClaudeCode,
268
+ role: Some("developer".to_string()),
269
+ declared_tools: Some(Vec::new()),
270
+ system_prompt_inline: None,
271
+ system_prompt_file: None,
272
+ output_contract_format: None,
273
+ };
274
+ let tools =
275
+ resolved_tool_strings_for_command(&agent, Provider::ClaudeCode, &disabled_safety())
276
+ .unwrap();
277
+ assert_eq!(
278
+ tools,
279
+ [
280
+ "execute_bash",
281
+ "fs_list",
282
+ "fs_read",
283
+ "fs_write",
284
+ "git_diff",
285
+ "mcp_team",
286
+ "provider_builtin"
287
+ ]
288
+ );
289
+
290
+ let agent = WorkerCommandAgent {
291
+ declared_tools: Some(vec!["fs_*".to_string(), "@team-orchestrator".to_string()]),
292
+ ..agent
293
+ };
294
+ let tools =
295
+ resolved_tool_strings_for_command(&agent, Provider::ClaudeCode, &disabled_safety())
296
+ .unwrap();
297
+ assert_eq!(tools, ["fs_list", "fs_read", "fs_write", "mcp_team"]);
298
+ }
299
+
300
+ #[test]
301
+ fn system_prompt_uses_runtime_contract_identity_role_output_permissions_order() {
302
+ let agent = WorkerCommandAgent {
303
+ id: Some("coder".to_string()),
304
+ provider: Provider::Codex,
305
+ role: Some("Runtime Developer".to_string()),
306
+ declared_tools: Some(vec!["mcp_team".to_string()]),
307
+ system_prompt_inline: Some("Implement the assigned slice.".to_string()),
308
+ system_prompt_file: None,
309
+ output_contract_format: Some("result_envelope_v1".to_string()),
310
+ };
311
+ let prompt = compile_worker_system_prompt(&agent).unwrap();
312
+ let runtime = prompt
313
+ .find(RUNTIME_CONTRACT_SECTION.lines().next().unwrap_or(""))
314
+ .unwrap();
315
+ let identity = prompt.find("worker `coder`").unwrap();
316
+ let role = prompt.find("Implement the assigned slice.").unwrap();
317
+ let output = prompt
318
+ .find("Final completion must call team_orchestrator.report_result exactly once")
319
+ .unwrap();
320
+ let permissions = prompt.find("Permission note:").unwrap();
321
+ assert!(runtime < identity && identity < role && role < output && output < permissions);
322
+ let slowdown_phrase = format!("500/{}", 500 + 29);
323
+ assert!(prompt.contains(&slowdown_phrase));
324
+ assert!(prompt.contains("Runtime Developer"));
325
+ }
326
+ }
@@ -318,6 +318,60 @@ pub(crate) fn socket_name_for_workspace(workspace: &Path) -> String {
318
318
  format!("ta-{:012x}", hasher.finish() & 0xffff_ffff_ffff)
319
319
  }
320
320
 
321
+ pub(crate) fn socket_path_for_workspace(workspace: &Path) -> Option<PathBuf> {
322
+ let socket_name = socket_name_for_workspace(workspace);
323
+ let roots = tmux_socket_roots();
324
+ for root in &roots {
325
+ let root = root.canonicalize().unwrap_or_else(|_| root.clone());
326
+ let candidate = root.join(&socket_name);
327
+ if candidate.exists() {
328
+ return Some(candidate.canonicalize().unwrap_or(candidate));
329
+ }
330
+ }
331
+ let uid = unsafe { libc::geteuid() };
332
+ let default_root = PathBuf::from(format!("/tmp/tmux-{uid}"));
333
+ let default_root = default_root
334
+ .canonicalize()
335
+ .unwrap_or(default_root);
336
+ Some(default_root.join(socket_name))
337
+ }
338
+
339
+ pub(crate) fn attach_command_for_workspace(
340
+ workspace: &Path,
341
+ session_name: &SessionName,
342
+ window_name: &str,
343
+ ) -> Option<String> {
344
+ let socket_path = socket_path_for_workspace(workspace)?;
345
+ Some(format!(
346
+ "tmux -S {} attach -t {}:{}",
347
+ socket_path.display(),
348
+ session_name.as_str(),
349
+ window_name
350
+ ))
351
+ }
352
+
353
+ pub(crate) fn attach_commands_for_windows<'a>(
354
+ workspace: &Path,
355
+ session_name: &SessionName,
356
+ window_names: impl IntoIterator<Item = &'a str>,
357
+ ) -> Vec<String> {
358
+ window_names
359
+ .into_iter()
360
+ .filter_map(|window_name| attach_command_for_workspace(workspace, session_name, window_name))
361
+ .collect()
362
+ }
363
+
364
+ fn tmux_socket_roots() -> Vec<PathBuf> {
365
+ let uid = unsafe { libc::geteuid() };
366
+ let mut roots = vec![PathBuf::from(format!("/tmp/tmux-{uid}"))];
367
+ if let Some(tmpdir) = std::env::var_os("TMPDIR") {
368
+ roots.push(PathBuf::from(tmpdir).join(format!("tmux-{uid}")));
369
+ }
370
+ roots.sort();
371
+ roots.dedup();
372
+ roots
373
+ }
374
+
321
375
  pub(crate) fn socket_name_from_tmux_env() -> Option<String> {
322
376
  let tmux = std::env::var("TMUX")
323
377
  .ok()