@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
@@ -75,7 +75,8 @@ pub(crate) fn start_agent_at_paths(
75
75
  let agent = state
76
76
  .get("agents")
77
77
  .and_then(|v| v.get(agent_id.as_str()))
78
- .ok_or_else(|| LifecycleError::RequirementUnmet(format!("agent {agent_id} not found")))?;
78
+ .ok_or_else(|| LifecycleError::RequirementUnmet(format!("agent {agent_id} not found")))?
79
+ .clone();
79
80
  if agent
80
81
  .get("paused")
81
82
  .and_then(serde_json::Value::as_bool)
@@ -84,7 +85,7 @@ pub(crate) fn start_agent_at_paths(
84
85
  return Ok(StartAgentOutcome::Paused { agent_id: agent_id.clone() });
85
86
  }
86
87
  let session_name = state_session_name(&state);
87
- let window = agent_window(agent, agent_id);
88
+ let window = agent_window(&agent, agent_id);
88
89
  if !force && window_exists(transport, &session_name, &window) {
89
90
  mark_agent_running_noop(&mut state, agent_id, &session_name, &window)?;
90
91
  crate::state::projection::save_team_scoped_state(workspace, &state)
@@ -104,9 +105,9 @@ pub(crate) fn start_agent_at_paths(
104
105
  target,
105
106
  });
106
107
  }
107
- let provider = agent_provider(agent);
108
- let session_id = agent_session_id(agent);
109
- let rollout_path = agent_rollout_path(agent);
108
+ let provider = agent_provider(&agent);
109
+ let session_id = agent_session_id(&agent);
110
+ let rollout_path = agent_rollout_path(&agent);
110
111
  let rollout_exists = rollout_path
111
112
  .as_ref()
112
113
  .map(|p| p.as_path().exists())
@@ -125,20 +126,25 @@ pub(crate) fn start_agent_at_paths(
125
126
  };
126
127
  let into_existing_session =
127
128
  session_live_or_default(transport, &session_name, session_name_present(&state));
129
+ let safety = crate::lifecycle::launch::effective_runtime_config_for_worker_spawn()?;
128
130
  let spawn = spawn_agent_window(
129
131
  workspace,
130
132
  &session_name,
131
133
  agent_id,
132
- agent,
134
+ &agent,
133
135
  spawn_session_id,
134
136
  into_existing_session,
135
137
  transport,
138
+ Some(&safety),
136
139
  None,
137
140
  )?;
141
+ mark_agent_started(&mut state, agent_id, &window, &spawn, &safety)?;
142
+ crate::state::projection::save_team_scoped_state(workspace, &state)
143
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
138
144
  write_start_agent_start_event(
139
145
  workspace,
140
146
  agent_id,
141
- agent,
147
+ &agent,
142
148
  provider,
143
149
  start_mode,
144
150
  &session_name,
@@ -154,7 +160,7 @@ pub(crate) fn start_agent_at_paths(
154
160
  coordinator_started,
155
161
  },
156
162
  start_mode,
157
- target: spawn.pane_id.as_str().to_string(),
163
+ target: spawn.spawn.pane_id.as_str().to_string(),
158
164
  session_id,
159
165
  rollout_path,
160
166
  })
@@ -281,6 +287,43 @@ pub(super) fn resolve_team_scoped_state_or_refuse(
281
287
  state.ok_or_else(|| LifecycleError::StatePersist("resolve_team_scoped_state returned no state".to_string()))
282
288
  }
283
289
 
290
+ fn mark_agent_started(
291
+ state: &mut serde_json::Value,
292
+ agent_id: &AgentId,
293
+ window: &str,
294
+ spawn: &SpawnedAgentWindow,
295
+ safety: &DangerousApproval,
296
+ ) -> Result<(), LifecycleError> {
297
+ let Some(agent) = state
298
+ .get_mut("agents")
299
+ .and_then(serde_json::Value::as_object_mut)
300
+ .and_then(|agents| agents.get_mut(agent_id.as_str()))
301
+ .and_then(serde_json::Value::as_object_mut)
302
+ else {
303
+ return Err(LifecycleError::StatePersist(format!(
304
+ "agent {} state is not an object",
305
+ agent_id
306
+ )));
307
+ };
308
+ agent.insert("status".to_string(), serde_json::json!("running"));
309
+ agent.insert("agent_id".to_string(), serde_json::json!(agent_id.as_str()));
310
+ agent.insert("window".to_string(), serde_json::json!(window));
311
+ agent.insert(
312
+ "pane_id".to_string(),
313
+ serde_json::json!(spawn.spawn.pane_id.as_str()),
314
+ );
315
+ if let Some(pane_pid) = spawn.spawn.child_pid {
316
+ agent.insert("pane_pid".to_string(), serde_json::json!(pane_pid));
317
+ }
318
+ crate::lifecycle::launch::persist_command_plan_state(
319
+ agent,
320
+ &spawn.plan,
321
+ &spawn.profile_launch,
322
+ );
323
+ crate::lifecycle::launch::persist_effective_approval_policy(agent, safety);
324
+ Ok(())
325
+ }
326
+
284
327
  /// `reset_agent(workspace, agent_id, discard_session, open_display, team)`
285
328
  /// (`lifecycle/operations.py:102`)。discard + 重起;**未传 discard_session → 拒绝**。
286
329
  pub fn reset_agent(
@@ -415,25 +458,58 @@ fn write_start_agent_start_event(
415
458
  let mcp_config = adapter
416
459
  .mcp_config(auth_mode)
417
460
  .map_err(|e| LifecycleError::Provider(e.to_string()))?;
418
- let mut argv = match session_id {
461
+ let team_id = agent
462
+ .get("owner_team_id")
463
+ .and_then(|v| v.as_str());
464
+ let mcp_config = crate::lifecycle::launch::resolve_mcp_config(
465
+ mcp_config,
466
+ workspace,
467
+ agent_id.as_str(),
468
+ team_id.unwrap_or(""),
469
+ );
470
+ let mcp_config_path =
471
+ crate::lifecycle::launch::write_worker_mcp_config(workspace, agent_id.as_str(), &mcp_config)?;
472
+ let profile_launch =
473
+ crate::lifecycle::profile_launch::prepare_provider_profile_launch_from_json(
474
+ workspace,
475
+ agent_id.as_str(),
476
+ agent,
477
+ Some(&mcp_config),
478
+ )?;
479
+ let command_model = profile_launch
480
+ .command_overrides
481
+ .model
482
+ .as_deref()
483
+ .or(model);
484
+ let context = crate::provider::ProviderCommandContext {
485
+ auth_mode,
486
+ mcp_config: Some(&mcp_config),
487
+ system_prompt: role,
488
+ model: command_model,
489
+ tools: &tool_refs,
490
+ profile_launch: Some(&profile_launch),
491
+ };
492
+ let mut plan = match session_id {
419
493
  Some(session_id) => adapter
420
- .build_resume_command_with_context(
421
- Some(session_id),
422
- auth_mode,
423
- Some(&mcp_config),
424
- role,
425
- model,
426
- &tool_refs,
427
- )
494
+ .build_resume_command_plan(Some(session_id), context)
428
495
  .map_err(|e| LifecycleError::Provider(e.to_string()))?,
429
496
  None => adapter
430
- .build_command_with_tools(auth_mode, Some(&mcp_config), role, model, &tool_refs)
497
+ .build_command_plan(context)
431
498
  .map_err(|e| LifecycleError::Provider(e.to_string()))?,
432
499
  };
433
- let team_id = agent
434
- .get("owner_team_id")
435
- .and_then(|v| v.as_str());
436
- crate::lifecycle::launch::fill_spawn_placeholders_full(&mut argv, workspace, agent_id.as_str(), team_id);
500
+ if !plan.managed_mcp_config && !profile_launch.managed_mcp_config {
501
+ crate::lifecycle::launch::point_native_mcp_config_at_file(
502
+ &mut plan.argv,
503
+ provider,
504
+ &mcp_config_path,
505
+ );
506
+ }
507
+ crate::lifecycle::launch::fill_spawn_placeholders_full(
508
+ &mut plan.argv,
509
+ workspace,
510
+ agent_id.as_str(),
511
+ team_id,
512
+ );
437
513
  let tmux_start_mode = if into_existing_session {
438
514
  "new-window"
439
515
  } else {
@@ -450,7 +526,7 @@ fn write_start_agent_start_event(
450
526
  "session": session_name.as_str(),
451
527
  "window": window,
452
528
  "tmux_start_mode": tmux_start_mode,
453
- "command": argv,
529
+ "command": plan.argv,
454
530
  "mcp_config": agent.get("mcp_config").cloned().unwrap_or(serde_json::Value::Null),
455
531
  }),
456
532
  )
@@ -1,5 +1,11 @@
1
1
  use super::*;
2
2
 
3
+ pub(super) struct SpawnedAgentWindow {
4
+ pub spawn: crate::transport::SpawnResult,
5
+ pub plan: crate::provider::CommandPlan,
6
+ pub profile_launch: crate::provider::ProviderProfileLaunch,
7
+ }
8
+
3
9
  pub(super) fn spawn_agent_window(
4
10
  workspace: &Path,
5
11
  session_name: &SessionName,
@@ -9,7 +15,8 @@ pub(super) fn spawn_agent_window(
9
15
  into_existing_session: bool,
10
16
  transport: &dyn crate::transport::Transport,
11
17
  safety: Option<&DangerousApproval>,
12
- ) -> Result<crate::transport::SpawnResult, LifecycleError> {
18
+ spawn_cwd_override: Option<&Path>,
19
+ ) -> Result<SpawnedAgentWindow, LifecycleError> {
13
20
  let provider = agent_provider(agent);
14
21
  let auth_mode = agent_auth_mode(agent);
15
22
  let model = agent.get("model").and_then(|v| v.as_str());
@@ -32,24 +39,6 @@ pub(super) fn spawn_agent_window(
32
39
  };
33
40
  let tools = crate::lifecycle::launch::worker_tool_refs(agent_tool_strings(agent), safety);
34
41
  let tool_refs: Vec<&str> = tools.iter().map(String::as_str).collect();
35
- let mcp_config = adapter
36
- .mcp_config(auth_mode)
37
- .map_err(|e| LifecycleError::Provider(e.to_string()))?;
38
- let mut argv = match resume_session_id {
39
- Some(session_id) => adapter
40
- .build_resume_command_with_context(
41
- Some(session_id),
42
- auth_mode,
43
- Some(&mcp_config),
44
- role,
45
- model,
46
- &tool_refs,
47
- )
48
- .map_err(|e| LifecycleError::Provider(e.to_string()))?,
49
- None => adapter
50
- .build_command_with_tools(auth_mode, Some(&mcp_config), role, model, &tool_refs)
51
- .map_err(|e| LifecycleError::Provider(e.to_string()))?,
52
- };
53
42
  // owner_team_id resolution: prefer the runtime-state row's `owner_team_id` (set by
54
43
  // launch/restart); fall back to the active team key for paths that don't write the
55
44
  // row first (e.g. add-agent calls spawn before upserting team metadata).
@@ -63,22 +52,78 @@ pub(super) fn spawn_agent_window(
63
52
  let key = crate::messaging::leader_receiver::active_team_key(workspace, &state_for_team);
64
53
  (!key.is_empty()).then_some(key)
65
54
  });
55
+ let mcp_config = adapter
56
+ .mcp_config(auth_mode)
57
+ .map_err(|e| LifecycleError::Provider(e.to_string()))?;
58
+ let mcp_config = crate::lifecycle::launch::resolve_mcp_config(
59
+ mcp_config,
60
+ workspace,
61
+ agent_id.as_str(),
62
+ team_id.as_deref().unwrap_or(""),
63
+ );
64
+ let mcp_config_path =
65
+ crate::lifecycle::launch::write_worker_mcp_config(workspace, agent_id.as_str(), &mcp_config)?;
66
+ let profile_launch =
67
+ crate::lifecycle::profile_launch::prepare_provider_profile_launch_from_json(
68
+ workspace,
69
+ agent_id.as_str(),
70
+ agent,
71
+ Some(&mcp_config),
72
+ )?;
73
+ let command_model = profile_launch
74
+ .command_overrides
75
+ .model
76
+ .as_deref()
77
+ .or(model);
78
+ let context = crate::provider::ProviderCommandContext {
79
+ auth_mode,
80
+ mcp_config: Some(&mcp_config),
81
+ system_prompt: role,
82
+ model: command_model,
83
+ tools: &tool_refs,
84
+ profile_launch: Some(&profile_launch),
85
+ };
86
+ let mut plan = match resume_session_id {
87
+ Some(session_id) => adapter
88
+ .build_resume_command_plan(Some(session_id), context)
89
+ .map_err(|e| LifecycleError::Provider(e.to_string()))?,
90
+ None => adapter
91
+ .build_command_plan(context)
92
+ .map_err(|e| LifecycleError::Provider(e.to_string()))?,
93
+ };
94
+ if !plan.managed_mcp_config && !profile_launch.managed_mcp_config {
95
+ crate::lifecycle::launch::point_native_mcp_config_at_file(
96
+ &mut plan.argv,
97
+ provider,
98
+ &mcp_config_path,
99
+ );
100
+ }
66
101
  crate::lifecycle::launch::fill_spawn_placeholders_full(
67
- &mut argv,
102
+ &mut plan.argv,
68
103
  workspace,
69
104
  agent_id.as_str(),
70
105
  team_id.as_deref(),
71
106
  );
72
107
  let window = WindowName::new(agent_id.as_str());
73
- let env = crate::lifecycle::launch::inherited_env_with_team_overrides(
108
+ let mut env = crate::lifecycle::launch::inherited_env_with_team_overrides(
74
109
  workspace,
75
110
  agent_id.as_str(),
76
111
  team_id.as_deref(),
77
112
  );
113
+ crate::lifecycle::launch::apply_profile_launch_env(&mut env, &profile_launch);
114
+ let spawn_cwd = spawn_cwd_override
115
+ .or_else(|| {
116
+ agent
117
+ .get("spawn_cwd")
118
+ .and_then(|v| v.as_str())
119
+ .filter(|cwd| !cwd.is_empty())
120
+ .map(Path::new)
121
+ })
122
+ .unwrap_or(workspace);
78
123
  let result = if into_existing_session {
79
- transport.spawn_into(session_name, &window, &argv, workspace, &env)
124
+ transport.spawn_into(session_name, &window, &plan.argv, spawn_cwd, &env)
80
125
  } else {
81
- transport.spawn_first(session_name, &window, &argv, workspace, &env)
126
+ transport.spawn_first(session_name, &window, &plan.argv, spawn_cwd, &env)
82
127
  };
83
128
  let spawn = result.map_err(|e| LifecycleError::Transport(e.to_string()))?;
84
129
  let _ = adapter.handle_startup_prompts(
@@ -87,7 +132,11 @@ pub(super) fn spawn_agent_window(
87
132
  30,
88
133
  0.5,
89
134
  );
90
- Ok(spawn)
135
+ Ok(SpawnedAgentWindow {
136
+ spawn,
137
+ plan,
138
+ profile_launch,
139
+ })
91
140
  }
92
141
 
93
142
  pub(super) fn start_coordinator_for_workspace(workspace: &Path) -> Result<bool, LifecycleError> {
@@ -97,6 +146,13 @@ pub(super) fn start_coordinator_for_workspace(workspace: &Path) -> Result<bool,
97
146
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))
98
147
  }
99
148
 
149
+ pub(super) fn persist_effective_approval_policy_for_restart(
150
+ agent: &mut serde_json::Map<String, serde_json::Value>,
151
+ safety: &DangerousApproval,
152
+ ) {
153
+ crate::lifecycle::launch::persist_effective_approval_policy(agent, safety);
154
+ }
155
+
100
156
  pub(super) fn state_session_name(state: &serde_json::Value) -> SessionName {
101
157
  state
102
158
  .get("session_name")
@@ -160,6 +216,109 @@ pub(super) fn agent_rollout_path(agent: &serde_json::Value) -> Option<RolloutPat
160
216
  .map(RolloutPath::new)
161
217
  }
162
218
 
219
+ pub(crate) fn refresh_missing_provider_sessions(
220
+ state: &mut serde_json::Value,
221
+ ) -> Result<bool, LifecycleError> {
222
+ crate::session_capture::capture_missing_provider_sessions_once(
223
+ state,
224
+ &mut crate::provider::get_adapter,
225
+ false,
226
+ 0,
227
+ )
228
+ .map(|report| report.changed)
229
+ .map_err(|e| LifecycleError::Provider(e.to_string()))
230
+ }
231
+
232
+ pub(crate) fn converge_missing_provider_sessions(
233
+ state: &mut serde_json::Value,
234
+ deadline: std::time::Duration,
235
+ poll_interval: std::time::Duration,
236
+ workspace: &Path,
237
+ allow_fresh: bool,
238
+ ) -> Result<crate::session_capture::SessionConvergence, LifecycleError> {
239
+ crate::session_capture::converge_missing_provider_sessions(
240
+ state,
241
+ &mut crate::provider::get_adapter,
242
+ deadline,
243
+ poll_interval,
244
+ restart_required_missing_session_agent_ids,
245
+ |progress| {
246
+ let pending_agent_ids = progress.pending_agent_ids.clone();
247
+ write_session_convergence_progress_event(
248
+ workspace,
249
+ serde_json::json!({
250
+ "ts": chrono::Utc::now().to_rfc3339(),
251
+ "event": "provider.session.converging",
252
+ "iteration": progress.iteration,
253
+ "elapsed_ms": progress.elapsed_ms,
254
+ "deadline_ms": progress.deadline_ms,
255
+ "changed": progress.changed,
256
+ "assigned": progress.assigned,
257
+ "missing": progress.missing,
258
+ "required_missing": progress.required_missing_agent_ids.clone(),
259
+ "required_missing_agent_ids": progress.required_missing_agent_ids,
260
+ "pending": pending_agent_ids,
261
+ "pending_agent_ids": progress.pending_agent_ids,
262
+ "candidate_count_by_agent": progress.candidate_count_by_agent,
263
+ "remaining_ms": progress.remaining_ms,
264
+ "allow_fresh": allow_fresh,
265
+ }),
266
+ )
267
+ },
268
+ )
269
+ .map_err(LifecycleError::StatePersist)
270
+ }
271
+
272
+ fn write_session_convergence_progress_event(
273
+ workspace: &Path,
274
+ event: serde_json::Value,
275
+ ) -> Result<(), String> {
276
+ use std::io::Write as _;
277
+
278
+ let path = workspace.join(".team").join("logs").join("events.jsonl");
279
+ if let Some(parent) = path.parent() {
280
+ std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
281
+ }
282
+ let line = serde_json::to_string(&event).map_err(|e| e.to_string())?;
283
+ let mut file = std::fs::OpenOptions::new()
284
+ .create(true)
285
+ .append(true)
286
+ .open(path)
287
+ .map_err(|e| e.to_string())?;
288
+ file.write_all(line.as_bytes())
289
+ .and_then(|_| file.write_all(b"\n"))
290
+ .map_err(|e| e.to_string())
291
+ }
292
+
293
+ pub(crate) fn restart_required_missing_session_agent_ids(state: &serde_json::Value) -> Vec<String> {
294
+ let mut missing = crate::session_capture::incomplete_resumable_agent_ids(state)
295
+ .into_iter()
296
+ .filter(|agent_id| {
297
+ let Some(agent) = state.get("agents").and_then(|agents| agents.get(agent_id)) else {
298
+ return false;
299
+ };
300
+ let missing_session_id = agent
301
+ .get("session_id")
302
+ .and_then(|value| value.as_str())
303
+ .is_none_or(|session| session.is_empty());
304
+ let is_running = agent
305
+ .get("status")
306
+ .and_then(|value| value.as_str())
307
+ .is_some_and(|status| status == "running");
308
+ let has_live_pane_binding = agent
309
+ .get("pane_id")
310
+ .and_then(|value| value.as_str())
311
+ .is_some_and(|pane| !pane.is_empty());
312
+ let has_interaction_marker = agent
313
+ .get("first_send_at")
314
+ .and_then(|value| value.as_str())
315
+ .is_some_and(|value| !value.is_empty());
316
+ missing_session_id && is_running && (has_live_pane_binding || has_interaction_marker)
317
+ })
318
+ .collect::<Vec<_>>();
319
+ missing.sort();
320
+ missing
321
+ }
163
322
  /// Tools list off an agent's runtime state entry (`tools: [...]`). Restart paths
164
323
  /// don't have the full spec object, only the runtime state — so they read tools from
165
324
  /// the state row, falling back to an empty list. Contract C requires the worker