@team-agent/installer 0.5.51 → 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 (79) 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/named_address.rs +35 -67
  7. package/crates/team-agent/src/cli/spec.rs +3 -0
  8. package/crates/team-agent/src/cli/tests/main_preserved.rs +1 -0
  9. package/crates/team-agent/src/cli/types.rs +11 -0
  10. package/crates/team-agent/src/diagnose/orphans.rs +24 -3
  11. package/crates/team-agent/src/kill_audit.rs +67 -0
  12. package/crates/team-agent/src/leader/lease.rs +526 -73
  13. package/crates/team-agent/src/leader/rediscover/tests.rs +3 -0
  14. package/crates/team-agent/src/leader/rediscover.rs +6 -0
  15. package/crates/team-agent/src/leader/start.rs +144 -23
  16. package/crates/team-agent/src/leader/tests/idle.rs +3 -0
  17. package/crates/team-agent/src/leader/types.rs +6 -0
  18. package/crates/team-agent/src/lib.rs +1 -0
  19. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +1 -1
  20. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +12 -1
  21. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +4 -0
  22. package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +106 -0
  23. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +216 -208
  24. package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +36 -0
  25. package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +238 -0
  26. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +101 -5
  27. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +1 -1
  28. package/crates/team-agent/src/lifecycle/launch/role_source.rs +170 -0
  29. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +8 -8
  30. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +1 -1
  31. package/crates/team-agent/src/lifecycle/launch.rs +14 -2
  32. package/crates/team-agent/src/lifecycle/restart/agent.rs +20 -4
  33. package/crates/team-agent/src/lifecycle/restart/common.rs +12 -0
  34. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +113 -4
  35. package/crates/team-agent/src/lifecycle/restart/remove.rs +73 -10
  36. package/crates/team-agent/src/lifecycle/restart.rs +25 -1
  37. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +441 -12
  38. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +20 -2
  39. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +1 -0
  40. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +3 -1
  41. package/crates/team-agent/src/lifecycle/types.rs +17 -0
  42. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +40 -5
  43. package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +1 -1
  44. package/crates/team-agent/src/mcp_server/tests/tools.rs +18 -1
  45. package/crates/team-agent/src/mcp_server/tests/wire.rs +243 -167
  46. package/crates/team-agent/src/mcp_server/tests.rs +11 -4
  47. package/crates/team-agent/src/mcp_server/tools.rs +18 -4
  48. package/crates/team-agent/src/mcp_server/types.rs +3 -0
  49. package/crates/team-agent/src/mcp_server/wire.rs +30 -7
  50. package/crates/team-agent/src/messaging/delivery.rs +126 -147
  51. package/crates/team-agent/src/messaging/leader_channel.rs +198 -0
  52. package/crates/team-agent/src/messaging/leader_receiver.rs +68 -21
  53. package/crates/team-agent/src/messaging/mod.rs +5 -0
  54. package/crates/team-agent/src/messaging/tests/leader_channel.rs +169 -0
  55. package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
  56. package/crates/team-agent/src/messaging/tests/runtime.rs +34 -23
  57. package/crates/team-agent/src/provider/adapter.rs +54 -13
  58. package/crates/team-agent/src/provider/adapters/claude_fork.rs +122 -0
  59. package/crates/team-agent/src/provider/adapters/copilot_fork.rs +306 -0
  60. package/crates/team-agent/src/provider/adapters/mod.rs +2 -0
  61. package/crates/team-agent/src/provider/session/capture.rs +321 -52
  62. package/crates/team-agent/src/provider/session/context_fork.rs +499 -0
  63. package/crates/team-agent/src/provider/session/mod.rs +6 -0
  64. package/crates/team-agent/src/provider/session_scan/claude.rs +1 -1
  65. package/crates/team-agent/src/provider/session_scan/codex.rs +144 -27
  66. package/crates/team-agent/src/provider/session_scan/common.rs +39 -6
  67. package/crates/team-agent/src/provider/session_scan/copilot.rs +47 -3
  68. package/crates/team-agent/src/provider/session_scan.rs +3 -3
  69. package/crates/team-agent/src/provider/tests/copilot_fork.rs +191 -0
  70. package/crates/team-agent/src/provider/tests.rs +1 -0
  71. package/crates/team-agent/src/state/persist.rs +282 -20
  72. package/crates/team-agent/src/state/projection.rs +245 -30
  73. package/crates/team-agent/src/state/repository/intent.rs +13 -0
  74. package/crates/team-agent/src/state/repository.rs +36 -12
  75. package/crates/team-agent/src/state/selector.rs +9 -18
  76. package/crates/team-agent/src/tmux_backend/tests.rs +51 -16
  77. package/crates/team-agent/src/tmux_backend.rs +29 -2
  78. package/package.json +4 -4
  79. package/skills/team-agent/SKILL.md +8 -3
@@ -0,0 +1,238 @@
1
+ use super::*;
2
+
3
+ pub(super) struct ForkPostSpawnInput<'a> {
4
+ pub workspace: &'a Path,
5
+ pub transport: &'a dyn Transport,
6
+ pub session_name: &'a SessionName,
7
+ pub window: &'a WindowName,
8
+ pub mcp_config_path: &'a Path,
9
+ pub agent_id: &'a AgentId,
10
+ pub profile_launch: &'a crate::provider::ProviderProfileLaunch,
11
+ pub team_key: &'a str,
12
+ pub spawn: &'a crate::transport::SpawnResult,
13
+ }
14
+
15
+ pub(super) fn ensure_fork_spawn_live(input: ForkPostSpawnInput<'_>) -> Result<(), LifecycleError> {
16
+ let rollback = || {
17
+ rollback_fork_after_spawn(
18
+ input.workspace,
19
+ input.transport,
20
+ input.session_name,
21
+ input.window,
22
+ input.mcp_config_path,
23
+ input.agent_id,
24
+ input.profile_launch,
25
+ input.team_key,
26
+ );
27
+ };
28
+ if !matches!(
29
+ input.transport.liveness(&input.spawn.pane_id),
30
+ Ok(PaneLiveness::Live)
31
+ ) {
32
+ rollback();
33
+ return Err(LifecycleError::RequirementUnmet(format!(
34
+ "fork process is not live after spawn: agent={} pane={}",
35
+ input.agent_id,
36
+ input.spawn.pane_id.as_str()
37
+ )));
38
+ }
39
+ Ok(())
40
+ }
41
+
42
+ pub(super) fn prepare_claude_fork_backing(
43
+ provider: Provider,
44
+ plan: &crate::provider::CommandPlan,
45
+ source_backing: &Path,
46
+ source_session_id: &crate::provider::SessionId,
47
+ ) -> Result<Option<crate::provider::adapters::claude_fork::ClaudeForkMaterialization>, LifecycleError>
48
+ {
49
+ if !matches!(provider, Provider::Claude | Provider::ClaudeCode) {
50
+ return Ok(None);
51
+ }
52
+ let target_session_id = plan.expected_session_id.as_ref().ok_or_else(|| {
53
+ LifecycleError::Provider("claude fork plan has no snapshot session id".to_string())
54
+ })?;
55
+ crate::provider::adapters::claude_fork::materialize_claude_fork(
56
+ source_backing,
57
+ source_session_id,
58
+ target_session_id,
59
+ )
60
+ .map(Some)
61
+ .map_err(|error| {
62
+ LifecycleError::Provider(format!(
63
+ "context_fork_unavailable: claude snapshot copy failed before spawn: {error}"
64
+ ))
65
+ })
66
+ }
67
+
68
+ pub(super) struct ForkCoordinatorInput<'a> {
69
+ pub workspace: &'a Path,
70
+ pub team_key: &'a str,
71
+ pub agent_id: &'a AgentId,
72
+ pub transport: &'a dyn Transport,
73
+ pub session_name: &'a SessionName,
74
+ pub window: &'a WindowName,
75
+ pub mcp_config_path: &'a Path,
76
+ pub profile_launch: &'a crate::provider::ProviderProfileLaunch,
77
+ }
78
+
79
+ pub(super) fn start_fork_coordinator(
80
+ input: ForkCoordinatorInput<'_>,
81
+ ) -> Result<bool, LifecycleError> {
82
+ let rollback = || {
83
+ rollback_fork_after_spawn(
84
+ input.workspace,
85
+ input.transport,
86
+ input.session_name,
87
+ input.window,
88
+ input.mcp_config_path,
89
+ input.agent_id,
90
+ input.profile_launch,
91
+ input.team_key,
92
+ );
93
+ };
94
+ if let Err(error) = maybe_fail_fork_after_spawn("start_coordinator") {
95
+ rollback();
96
+ return Err(error);
97
+ }
98
+ crate::coordinator::start_coordinator(&crate::coordinator::WorkspacePath::new(
99
+ input.workspace.to_path_buf(),
100
+ ))
101
+ .map(|report| report.ok)
102
+ .map_err(|error| {
103
+ rollback();
104
+ LifecycleError::StatePersist(error.to_string())
105
+ })
106
+ }
107
+
108
+ pub(super) struct ForkFinalizeInput<'a> {
109
+ pub workspace: &'a Path,
110
+ pub team_key: &'a str,
111
+ pub source_agent_id: &'a AgentId,
112
+ pub agent_id: &'a AgentId,
113
+ pub spec_agent: &'a Value,
114
+ pub safety: &'a DangerousApproval,
115
+ pub plan: &'a crate::provider::CommandPlan,
116
+ pub profile_launch: &'a crate::provider::ProviderProfileLaunch,
117
+ pub spawn: &'a crate::transport::SpawnResult,
118
+ pub profile_dir: &'a Path,
119
+ pub dynamic_role_file: &'a Path,
120
+ pub context_proof: &'a crate::provider::session::ContextForkProof,
121
+ pub spawned_at: &'a str,
122
+ pub spawn_epoch: u64,
123
+ }
124
+
125
+ pub(super) fn finalize_fork_state(input: ForkFinalizeInput<'_>) -> Result<(), LifecycleError> {
126
+ let _lock = acquire_agent_lifecycle_lock(LifecycleLockRequest {
127
+ workspace: input.workspace,
128
+ operation: "fork-agent-finalize",
129
+ team: Some(input.team_key),
130
+ agent_id: Some(input.agent_id),
131
+ })?;
132
+ let mut next_state = crate::state::selector::resolve_active_team(
133
+ input.workspace,
134
+ Some(input.team_key),
135
+ crate::state::selector::SelectorMode::RequireSpec,
136
+ )
137
+ .map_err(|error| LifecycleError::TeamSelect(error.to_string()))?
138
+ .state;
139
+ upsert_forked_agent_state(
140
+ &mut next_state,
141
+ input.source_agent_id,
142
+ input.agent_id,
143
+ input.spec_agent,
144
+ input.safety,
145
+ input.plan,
146
+ input.profile_launch,
147
+ input.spawn,
148
+ input.workspace,
149
+ Some(input.profile_dir),
150
+ input.dynamic_role_file,
151
+ input.context_proof,
152
+ input.spawned_at,
153
+ input.spawn_epoch,
154
+ )?;
155
+ if let Some(agent) = next_state
156
+ .get_mut("agents")
157
+ .and_then(serde_json::Value::as_object_mut)
158
+ .and_then(|agents| agents.get_mut(input.agent_id.as_str()))
159
+ .and_then(serde_json::Value::as_object_mut)
160
+ {
161
+ persist_effective_approval_policy(agent, input.safety);
162
+ }
163
+ maybe_fail_fork_after_spawn("save_runtime_state")?;
164
+ crate::state::repository::StateRepository::new(input.workspace)
165
+ .save(
166
+ crate::state::repository::StateWriteIntent::ForkAgent {
167
+ team_key: input.team_key,
168
+ agent_id: input.agent_id.as_str(),
169
+ },
170
+ &next_state,
171
+ )
172
+ .map_err(|error| LifecycleError::StatePersist(error.to_string()))
173
+ }
174
+
175
+ pub(super) fn verify_fork_registration(
176
+ workspace: &Path,
177
+ team_key: &str,
178
+ agent_id: &AgentId,
179
+ spawn: &crate::transport::SpawnResult,
180
+ window: &WindowName,
181
+ ) -> Result<(), LifecycleError> {
182
+ let saved = crate::state::projection::select_runtime_state(workspace, Some(team_key))
183
+ .map_err(|error| LifecycleError::StatePersist(error.to_string()))?;
184
+ let agent = saved
185
+ .get("agents")
186
+ .and_then(|agents| agents.get(agent_id.as_str()))
187
+ .ok_or_else(|| LifecycleError::StatePersist("canonical team row is missing".to_string()))?;
188
+ if agent.get("pane_id").and_then(serde_json::Value::as_str) != Some(spawn.pane_id.as_str()) {
189
+ return Err(LifecycleError::StatePersist(
190
+ "canonical team pane_id does not match spawned pane".to_string(),
191
+ ));
192
+ }
193
+ if agent.get("window").and_then(serde_json::Value::as_str) != Some(window.as_str()) {
194
+ return Err(LifecycleError::StatePersist(
195
+ "canonical team window does not match spawned window".to_string(),
196
+ ));
197
+ }
198
+ if let Some(pid) = spawn.child_pid {
199
+ if agent.get("pane_pid").and_then(serde_json::Value::as_u64) != Some(u64::from(pid)) {
200
+ return Err(LifecycleError::StatePersist(
201
+ "canonical team pane_pid does not match spawned process".to_string(),
202
+ ));
203
+ }
204
+ }
205
+ Ok(())
206
+ }
207
+
208
+ pub(super) fn rollback_fork_after_spawn(
209
+ workspace: &Path,
210
+ transport: &dyn Transport,
211
+ session_name: &SessionName,
212
+ window: &WindowName,
213
+ mcp_config_path: &Path,
214
+ agent_id: &AgentId,
215
+ profile_launch: &crate::provider::ProviderProfileLaunch,
216
+ team_key: &str,
217
+ ) {
218
+ let _ = transport.kill_window(&Target::SessionWindow {
219
+ session: session_name.clone(),
220
+ window: window.clone(),
221
+ });
222
+ if let Ok(_lock) = acquire_agent_lifecycle_lock(LifecycleLockRequest {
223
+ workspace,
224
+ operation: "fork-agent-rollback",
225
+ team: Some(team_key),
226
+ agent_id: Some(agent_id),
227
+ }) {
228
+ let _ = crate::lifecycle::restart::remove::remove_agent_with_transport_locked(
229
+ workspace,
230
+ agent_id,
231
+ true,
232
+ true,
233
+ Some(team_key),
234
+ transport,
235
+ );
236
+ }
237
+ cleanup_fork_mcp_artifacts(workspace, agent_id, mcp_config_path, profile_launch);
238
+ }
@@ -14,6 +14,61 @@ use crate::lifecycle::lock::{acquire_agent_lifecycle_lock, LifecycleLockRequest}
14
14
 
15
15
  use super::*;
16
16
 
17
+ pub(super) fn reserve_forked_agent_state(
18
+ state: &mut serde_json::Value,
19
+ source_agent_id: &AgentId,
20
+ as_agent_id: &AgentId,
21
+ spec_agent: &Value,
22
+ dynamic_role_file: &Path,
23
+ ) -> Result<(), LifecycleError> {
24
+ let agents = state
25
+ .as_object_mut()
26
+ .ok_or_else(|| {
27
+ LifecycleError::StatePersist("runtime state root is not an object".to_string())
28
+ })?
29
+ .entry("agents".to_string())
30
+ .or_insert_with(|| serde_json::json!({}));
31
+ let agent_map = agents.as_object_mut().ok_or_else(|| {
32
+ LifecycleError::StatePersist("runtime state agents is not an object".to_string())
33
+ })?;
34
+ if agent_map.contains_key(as_agent_id.as_str()) {
35
+ return Err(LifecycleError::RequirementUnmet(format!(
36
+ "agent id already exists: {as_agent_id}"
37
+ )));
38
+ }
39
+ crate::lifecycle::restart::remove::clear_agent_retirement_in_state(state, as_agent_id);
40
+ let agents = state
41
+ .get_mut("agents")
42
+ .and_then(serde_json::Value::as_object_mut)
43
+ .ok_or_else(|| {
44
+ LifecycleError::StatePersist("runtime state agents is not an object".to_string())
45
+ })?;
46
+ let mut entry = serde_json::json!({
47
+ "status": "forking",
48
+ "agent_id": as_agent_id.as_str(),
49
+ "window": as_agent_id.as_str(),
50
+ "forked_from": source_agent_id.as_str(),
51
+ "dynamic_role_file": dynamic_role_file.to_string_lossy().to_string(),
52
+ "role_source_ownership": "managed",
53
+ });
54
+ if let Some(object) = entry.as_object_mut() {
55
+ for key in [
56
+ "provider",
57
+ "auth_mode",
58
+ "model",
59
+ "profile",
60
+ "role",
61
+ "effort",
62
+ ] {
63
+ if let Some(value) = spec_agent.get(key) {
64
+ object.insert(key.to_string(), yaml_value_to_json(value));
65
+ }
66
+ }
67
+ }
68
+ agents.insert(as_agent_id.as_str().to_string(), entry);
69
+ Ok(())
70
+ }
71
+
17
72
  pub(super) fn maybe_fail_fork_after_spawn(step: &str) -> Result<(), LifecycleError> {
18
73
  let Ok(reason) = std::env::var("TEAM_AGENT_TEST_FAIL_FORK_AFTER_SPAWN") else {
19
74
  return Ok(());
@@ -42,6 +97,11 @@ pub(super) fn cleanup_fork_mcp_artifacts(
42
97
  .join(".team/runtime/provider-env")
43
98
  .join(format!("{}.env", agent_id.as_str())),
44
99
  );
100
+ let _ = std::fs::remove_dir_all(
101
+ workspace
102
+ .join(".team/runtime/copilot-instructions")
103
+ .join(agent_id.as_str()),
104
+ );
45
105
  if let Some(config_dir) = profile_launch.claude_config_dir.as_ref() {
46
106
  let _ = std::fs::remove_dir_all(config_dir.parent().unwrap_or(config_dir));
47
107
  }
@@ -202,6 +262,10 @@ pub(super) fn upsert_forked_agent_state(
202
262
  spawn: &crate::transport::SpawnResult,
203
263
  spawn_cwd: &Path,
204
264
  profile_dir: Option<&Path>,
265
+ dynamic_role_file: &Path,
266
+ context_proof: &crate::provider::session::ContextForkProof,
267
+ spawned_at: &str,
268
+ spawn_epoch: u64,
205
269
  ) -> Result<(), LifecycleError> {
206
270
  if !state.is_object() {
207
271
  *state = serde_json::json!({});
@@ -241,6 +305,14 @@ pub(super) fn upsert_forked_agent_state(
241
305
  "forked_from".to_string(),
242
306
  serde_json::json!(source_agent_id.as_str()),
243
307
  );
308
+ entry.insert(
309
+ "dynamic_role_file".to_string(),
310
+ serde_json::json!(dynamic_role_file.to_string_lossy().to_string()),
311
+ );
312
+ entry.insert(
313
+ "role_source_ownership".to_string(),
314
+ serde_json::json!("managed"),
315
+ );
244
316
  entry.insert(
245
317
  "spawn_cwd".to_string(),
246
318
  serde_json::json!(spawn_cwd.to_string_lossy().to_string()),
@@ -249,6 +321,8 @@ pub(super) fn upsert_forked_agent_state(
249
321
  "pane_id".to_string(),
250
322
  serde_json::json!(spawn.pane_id.as_str()),
251
323
  );
324
+ entry.insert("spawned_at".to_string(), serde_json::json!(spawned_at));
325
+ entry.insert("spawn_epoch".to_string(), serde_json::json!(spawn_epoch));
252
326
  if let Some(pid) = spawn.child_pid {
253
327
  entry.insert("pane_pid".to_string(), serde_json::json!(pid));
254
328
  }
@@ -274,14 +348,36 @@ pub(super) fn upsert_forked_agent_state(
274
348
  );
275
349
  }
276
350
  }
277
- entry.insert("session_id".to_string(), serde_json::Value::Null);
278
- entry.insert("rollout_path".to_string(), serde_json::Value::Null);
279
- entry.insert("captured_at".to_string(), serde_json::Value::Null);
280
- entry.insert("captured_via".to_string(), serde_json::Value::Null);
351
+ entry.insert(
352
+ "session_id".to_string(),
353
+ serde_json::json!(context_proof.new_session_id.as_str()),
354
+ );
355
+ entry.insert(
356
+ "rollout_path".to_string(),
357
+ serde_json::json!(context_proof.backing_path.to_string_lossy().to_string()),
358
+ );
359
+ entry.insert(
360
+ "captured_at".to_string(),
361
+ serde_json::json!(chrono::Utc::now().to_rfc3339()),
362
+ );
363
+ entry.insert(
364
+ "captured_via".to_string(),
365
+ serde_json::json!(context_proof.captured_via),
366
+ );
281
367
  entry.insert(
282
368
  "attribution_confidence".to_string(),
283
- serde_json::Value::Null,
369
+ serde_json::json!(context_proof.attribution_confidence),
284
370
  );
371
+ if let Some(root) = context_proof.managed_backing_root.as_ref() {
372
+ entry.insert(
373
+ "session_backing_root".to_string(),
374
+ serde_json::json!(root.to_string_lossy().to_string()),
375
+ );
376
+ entry.insert(
377
+ "session_backing_ownership".to_string(),
378
+ serde_json::json!("managed"),
379
+ );
380
+ }
285
381
  persist_command_plan_state(&mut entry, plan, profile_launch);
286
382
  agent_map.insert(
287
383
  as_agent_id.as_str().to_string(),
@@ -379,7 +379,7 @@ pub fn quick_start_with_transport_in_workspace_with_display(
379
379
  // to "unknown" in the payload) — Phase 2 refactor will thread
380
380
  // ResolvedTransport.source through the launch signature.
381
381
  annotate_runtime_transport(&mut state, transport, &workspace, None);
382
- save_launched_team_state_for_key(&workspace, &state, Some(&state_team_key))?;
382
+ save_launched_team_state_for_key(&workspace, &state, Some(&state_team_key), None)?;
383
383
  annotate_persisted_team_depth(
384
384
  &workspace,
385
385
  &state_team_key,
@@ -0,0 +1,170 @@
1
+ use std::path::{Path, PathBuf};
2
+
3
+ use crate::lifecycle::LifecycleError;
4
+ use crate::model::ids::AgentId;
5
+ use crate::model::yaml::{self, Value};
6
+
7
+ use super::set_yaml_map_value;
8
+
9
+ pub(super) struct MaterializedRole {
10
+ path: PathBuf,
11
+ keep: bool,
12
+ }
13
+
14
+ impl MaterializedRole {
15
+ pub(super) fn path(&self) -> &Path {
16
+ &self.path
17
+ }
18
+
19
+ pub(super) fn keep(&mut self) {
20
+ self.keep = true;
21
+ }
22
+ }
23
+
24
+ impl Drop for MaterializedRole {
25
+ fn drop(&mut self) {
26
+ if !self.keep {
27
+ let _ = std::fs::remove_file(&self.path);
28
+ }
29
+ }
30
+ }
31
+
32
+ pub(super) fn materialize_latest_role(
33
+ run_workspace: &Path,
34
+ team_dir: &Path,
35
+ state: &serde_json::Value,
36
+ source_agent_id: &AgentId,
37
+ as_agent_id: &AgentId,
38
+ label: Option<&str>,
39
+ ) -> Result<MaterializedRole, LifecycleError> {
40
+ let source_path = resolve_role_source(run_workspace, team_dir, state, source_agent_id)?;
41
+ let (mut meta, body) = crate::compiler::read_front_matter(&source_path)
42
+ .map_err(|error| LifecycleError::Compile(error.to_string()))?;
43
+ let declared = meta
44
+ .get("name")
45
+ .and_then(Value::as_str)
46
+ .filter(|name| !name.is_empty())
47
+ .ok_or_else(|| {
48
+ LifecycleError::Compile(format!(
49
+ "source role file does not declare name: {}",
50
+ source_path.display()
51
+ ))
52
+ })?;
53
+ if declared != source_agent_id.as_str() {
54
+ return Err(LifecycleError::Compile(format!(
55
+ "source role file declares name '{}' but source agent is '{}'",
56
+ declared, source_agent_id
57
+ )));
58
+ }
59
+ set_yaml_map_value(
60
+ &mut meta,
61
+ "name",
62
+ Value::Str(as_agent_id.as_str().to_string()),
63
+ )?;
64
+ if let Some(label) = label.filter(|value| !value.is_empty()) {
65
+ set_yaml_map_value(&mut meta, "role", Value::Str(label.to_string()))?;
66
+ }
67
+
68
+ let managed_dir = run_workspace.join(".team").join("dynamic-role-files");
69
+ std::fs::create_dir_all(&managed_dir)
70
+ .map_err(|error| LifecycleError::StatePersist(error.to_string()))?;
71
+ let path = managed_dir.join(format!("{}.md", as_agent_id.as_str()));
72
+ if path.exists() {
73
+ return Err(LifecycleError::RequirementUnmet(format!(
74
+ "managed role file already exists: {}",
75
+ path.display()
76
+ )));
77
+ }
78
+ let rendered = format!("---\n{}---\n\n{}", yaml::dumps(&meta), body);
79
+ let temp = path.with_extension(format!("md.tmp-{}", std::process::id()));
80
+ std::fs::write(&temp, rendered.as_bytes())
81
+ .map_err(|error| LifecycleError::StatePersist(error.to_string()))?;
82
+ if let Err(error) = std::fs::rename(&temp, &path) {
83
+ let _ = std::fs::remove_file(&temp);
84
+ return Err(LifecycleError::StatePersist(error.to_string()));
85
+ }
86
+ Ok(MaterializedRole { path, keep: false })
87
+ }
88
+
89
+ pub(super) fn clamp_materialized_role_to_leader(
90
+ materialized: &Path,
91
+ spec: &Value,
92
+ ) -> Result<(), LifecycleError> {
93
+ let leader_tools = spec
94
+ .get("leader")
95
+ .and_then(|leader| leader.get("tools"))
96
+ .and_then(Value::as_list)
97
+ .map(|items| {
98
+ items
99
+ .iter()
100
+ .filter_map(Value::as_str)
101
+ .map(str::to_string)
102
+ .collect::<Vec<_>>()
103
+ })
104
+ .unwrap_or_default();
105
+ let (mut meta, body) = crate::compiler::read_front_matter(materialized)
106
+ .map_err(|error| LifecycleError::Compile(error.to_string()))?;
107
+ let requested = meta
108
+ .get("tools")
109
+ .and_then(Value::as_list)
110
+ .map(|items| {
111
+ items
112
+ .iter()
113
+ .filter_map(Value::as_str)
114
+ .map(str::to_string)
115
+ .collect::<Vec<_>>()
116
+ })
117
+ .unwrap_or_default();
118
+ let ceiling = crate::model::permissions::expand_tool_strings(&leader_tools)
119
+ .into_iter()
120
+ .collect::<std::collections::BTreeSet<_>>();
121
+ let effective = crate::model::permissions::expand_tool_strings(&requested)
122
+ .into_iter()
123
+ .filter(|tool| ceiling.contains(tool))
124
+ .map(Value::Str)
125
+ .collect::<Vec<_>>();
126
+ set_yaml_map_value(&mut meta, "tools", Value::List(effective))?;
127
+ let rendered = format!("---\n{}---\n\n{}", yaml::dumps(&meta), body);
128
+ std::fs::write(materialized, rendered.as_bytes())
129
+ .map_err(|error| LifecycleError::StatePersist(error.to_string()))
130
+ }
131
+
132
+ fn resolve_role_source(
133
+ run_workspace: &Path,
134
+ team_dir: &Path,
135
+ state: &serde_json::Value,
136
+ source_agent_id: &AgentId,
137
+ ) -> Result<PathBuf, LifecycleError> {
138
+ if let Some(raw) = state
139
+ .get("agents")
140
+ .and_then(|agents| agents.get(source_agent_id.as_str()))
141
+ .and_then(|agent| agent.get("dynamic_role_file"))
142
+ .and_then(serde_json::Value::as_str)
143
+ .filter(|value| !value.is_empty())
144
+ {
145
+ let path = PathBuf::from(raw);
146
+ let resolved = if path.is_absolute() {
147
+ path
148
+ } else {
149
+ run_workspace.join(path)
150
+ };
151
+ if resolved.is_file() {
152
+ return Ok(resolved);
153
+ }
154
+ return Err(LifecycleError::Compile(format!(
155
+ "source dynamic role file not found: {}",
156
+ resolved.display()
157
+ )));
158
+ }
159
+ let path = team_dir
160
+ .join("agents")
161
+ .join(format!("{}.md", source_agent_id.as_str()));
162
+ if path.is_file() {
163
+ Ok(path)
164
+ } else {
165
+ Err(LifecycleError::Compile(format!(
166
+ "source role file not found: {}",
167
+ path.display()
168
+ )))
169
+ }
170
+ }
@@ -146,7 +146,7 @@ pub(super) fn persist_spawn_agent_state(
146
146
  obj.insert("agents".to_string(), serde_json::Value::Object(agents));
147
147
  state = serde_json::Value::Object(obj);
148
148
  }
149
- save_launched_team_state_for_key(workspace, &state, Some(&team_id))
149
+ save_launched_team_state_for_key(workspace, &state, Some(&team_id), None)
150
150
  }
151
151
 
152
152
  pub(super) fn pane_pids_by_started_agent(
@@ -178,13 +178,14 @@ pub(super) fn save_launched_team_state(
178
178
  workspace: &Path,
179
179
  launched: &serde_json::Value,
180
180
  ) -> Result<(), LifecycleError> {
181
- save_launched_team_state_for_key(workspace, launched, None)
181
+ save_launched_team_state_for_key(workspace, launched, None, None)
182
182
  }
183
183
 
184
184
  pub(super) fn save_launched_team_state_for_key(
185
185
  workspace: &Path,
186
186
  launched: &serde_json::Value,
187
187
  team_key: Option<&str>,
188
+ added_agent_id: Option<&str>,
188
189
  ) -> Result<(), LifecycleError> {
189
190
  let existing = load_runtime_state(workspace).unwrap_or_else(|_| serde_json::json!({}));
190
191
  let launched_key = team_key
@@ -231,13 +232,12 @@ pub(super) fn save_launched_team_state_for_key(
231
232
  };
232
233
  let mut projected = crate::state::projection::project_top_level_view(&merged, &launched_key);
233
234
  drop_unbound_top_level_owner(&mut projected);
235
+ let intent = crate::state::repository::StateWriteIntent::launch_team_or_add_agent(
236
+ &launched_key,
237
+ added_agent_id,
238
+ );
234
239
  crate::state::repository::StateRepository::new(workspace)
235
- .save(
236
- crate::state::repository::StateWriteIntent::LaunchTeam {
237
- team_key: &launched_key,
238
- },
239
- &projected,
240
- )
240
+ .save(intent, &projected)
241
241
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))
242
242
  }
243
243
 
@@ -18,7 +18,7 @@ pub(super) fn agent_is_paused(agent: &Value) -> bool {
18
18
  matches!(agent.get("paused"), Some(Value::Bool(true)))
19
19
  }
20
20
 
21
- pub(super) fn spawn_timestamp() -> String {
21
+ pub(crate) fn spawn_timestamp() -> String {
22
22
  match std::env::var("TEAM_AGENT_TEST_FIXED_SPAWNED_AT") {
23
23
  Ok(value) => value,
24
24
  Err(_) => chrono::Utc::now()
@@ -234,7 +234,7 @@ pub(super) use worker_env::*;
234
234
  pub(crate) use worker_env::{
235
235
  apply_copilot_instructions_overlay, apply_mcp_auto_approval_env, apply_profile_launch_env,
236
236
  fill_spawn_placeholders, fill_spawn_placeholders_full, inherited_env_with_team_overrides,
237
- persist_command_plan_state,
237
+ persist_command_plan_state, spawn_timestamp,
238
238
  };
239
239
 
240
240
  mod identity;
@@ -285,12 +285,24 @@ pub(crate) use add_agent_state::inject_agent_into_spec;
285
285
  pub(super) use add_agent_state::*;
286
286
 
287
287
  mod fork_agent;
288
+ pub use fork_agent::fork_agent_with_transport;
288
289
  pub(super) use fork_agent::*;
289
- pub use fork_agent::{fork_agent, fork_agent_with_transport};
290
+
291
+ mod fork_entry;
292
+ pub use fork_entry::fork_agent;
290
293
 
291
294
  mod fork_state;
292
295
  pub(super) use fork_state::*;
293
296
 
297
+ mod fork_finalize;
298
+ pub(super) use fork_finalize::*;
299
+
300
+ mod role_source;
301
+ pub(super) use role_source::*;
302
+
303
+ mod clone_agent;
304
+ pub use clone_agent::clone_agent;
305
+
294
306
  mod ownership;
295
307
  pub(super) use ownership::*;
296
308
  pub(crate) use ownership::{ensure_owner_allowed, ensure_owner_allowed_for_state, state_path};