@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
@@ -258,8 +258,7 @@ pub fn deliver_to_leader_fallback_pane(
258
258
  attempt_payload.clone(),
259
259
  )?;
260
260
 
261
- let Some(pane_id) = pane_id.filter(|pane| !pane.is_empty() && pane != "__team_agent_unbound__")
262
- else {
261
+ let Some(receiver) = leader_receiver_record(state) else {
263
262
  let failed = serde_json::json!({
264
263
  "message_id": message_id,
265
264
  "result_id": result_id,
@@ -267,7 +266,7 @@ pub fn deliver_to_leader_fallback_pane(
267
266
  "pane_id": null,
268
267
  "primary_error": primary_error,
269
268
  "delivered_via": "fallback_pane",
270
- "reason": "no_bound_pane",
269
+ "reason": "no_bound_receiver",
271
270
  });
272
271
  event_log.write("leader_receiver.fallback_pane_failed", failed)?;
273
272
  return Ok(DeliveryOutcome {
@@ -283,7 +282,6 @@ pub fn deliver_to_leader_fallback_pane(
283
282
  };
284
283
 
285
284
  let rendered = render_fallback_pane_message(content, message_id, primary_error);
286
- let target = Target::Pane(PaneId::new(&pane_id));
287
285
  let payload = InjectPayload::Text(rendered);
288
286
  // 0.5.5 gate054 cross-team notify boundary: when the leader receiver
289
287
  // has a recorded `tmux_socket` (canonical), that socket is a HARD
@@ -301,25 +299,63 @@ pub fn deliver_to_leader_fallback_pane(
301
299
  // stays pending in the store, coordinator surfaces the pending
302
300
  // notification via status/monitor, and no cross-server injection is
303
301
  // attempted.
304
- let inject_result: Result<InjectReport, TransportError> = match leader_tmux_socket(state) {
305
- Some(socket) => {
306
- let backend = crate::transport_factory::tmux_endpoint_transport(socket);
307
- backend.inject(&target, &payload, Key::Enter, true)
308
- }
309
- None => {
310
- // No socket recorded → legacy (pre-0.5) shape where the receiver
311
- // was implicitly bound to the workspace-canonical tmux server.
312
- // Keep the workspace→default chain here (single team assumption)
313
- // but never mix it with a recorded-socket team.
314
- let backend = crate::transport_factory::tmux_workspace_transport(workspace);
315
- backend
316
- .inject(&target, &payload, Key::Enter, true)
317
- .or_else(|_| {
318
- let backend = crate::transport_factory::tmux_default_transport();
319
- backend.inject(&target, &payload, Key::Enter, true)
320
- })
302
+ let socket_bound = leader_tmux_socket(state).is_some();
303
+ let transports: Vec<Box<dyn Transport>> = match leader_tmux_socket(state) {
304
+ Some(socket) => vec![Box::new(crate::transport_factory::tmux_endpoint_transport(
305
+ socket,
306
+ ))],
307
+ None => vec![
308
+ Box::new(crate::transport_factory::tmux_workspace_transport(
309
+ workspace,
310
+ )),
311
+ Box::new(crate::transport_factory::tmux_default_transport()),
312
+ ],
313
+ };
314
+ let mut resolved = None;
315
+ let mut refusal = "leader channel unavailable".to_string();
316
+ for transport in transports {
317
+ match super::leader_channel::resolve_live_leader_channel(
318
+ workspace,
319
+ receiver,
320
+ transport.as_ref(),
321
+ ) {
322
+ super::leader_channel::LeaderChannelResolution::Live(
323
+ super::leader_channel::LiveLeaderChannel::DirectTmux(channel),
324
+ ) => {
325
+ resolved = Some((transport, channel));
326
+ break;
327
+ }
328
+ other => refusal = format!("{other:?}"),
321
329
  }
330
+ }
331
+ let Some((transport, channel)) = resolved else {
332
+ event_log.write(
333
+ "leader_receiver.fallback_pane_failed",
334
+ serde_json::json!({
335
+ "message_id": message_id,
336
+ "result_id": result_id,
337
+ "owner_team_id": owner_team_id,
338
+ "pane_id": pane_id,
339
+ "primary_error": primary_error,
340
+ "delivered_via": "fallback_pane",
341
+ "socket_bound": socket_bound,
342
+ "reason": refusal,
343
+ }),
344
+ )?;
345
+ return Ok(DeliveryOutcome {
346
+ ok: false,
347
+ status: DeliveryStatus::Blocked,
348
+ message_status: MessageStatusShadow("blocked".to_string()),
349
+ message_id: Some(message_id.to_string()),
350
+ verification: Some("run team-agent claim-leader or team-agent takeover".to_string()),
351
+ stage: None,
352
+ reason: Some(DeliveryRefusal::LeaderNotAttached),
353
+ channel: Some("rebind_required".to_string()),
354
+ });
322
355
  };
356
+ let target = Target::Pane(PaneId::new(channel.pane_id));
357
+ let inject_result: Result<InjectReport, TransportError> =
358
+ transport.inject(&target, &payload, Key::Enter, true);
323
359
 
324
360
  match inject_result {
325
361
  Ok(report) => {
@@ -486,6 +522,17 @@ fn leader_record_field<'a>(state: &'a Value, field: &str) -> Option<&'a Value> {
486
522
  .or_else(|| receiver_or_owner_field(state, "team_owner", field))
487
523
  }
488
524
 
525
+ fn leader_receiver_record(state: &Value) -> Option<&Value> {
526
+ state.get("leader_receiver").or_else(|| {
527
+ let active = state.get("active_team_key").and_then(Value::as_str)?;
528
+ state
529
+ .get("teams")
530
+ .and_then(Value::as_object)
531
+ .and_then(|teams| teams.get(active))
532
+ .and_then(|team| team.get("leader_receiver"))
533
+ })
534
+ }
535
+
489
536
  fn owner_epoch_i64(state: &Value) -> Option<i64> {
490
537
  owner_epoch(state).and_then(|epoch| i64::try_from(epoch).ok())
491
538
  }
@@ -64,6 +64,7 @@ pub mod activity;
64
64
  pub mod address;
65
65
  pub mod delivery;
66
66
  pub mod helpers;
67
+ pub mod leader_channel;
67
68
  pub mod leader_receiver;
68
69
  pub mod peers;
69
70
  pub mod persist;
@@ -86,6 +87,10 @@ pub use delivery::{
86
87
  tmux_pane_width,
87
88
  };
88
89
  pub use helpers::fail_leader_delivery;
90
+ pub use leader_channel::{
91
+ resolve_live_leader_channel, DirectTmuxLeaderChannel, LeaderChannelResolution,
92
+ LeaderChannelUnbound, LiveLeaderChannel,
93
+ };
89
94
  pub use leader_receiver::{
90
95
  deliver_to_leader_fallback_pane, enqueue_leader_mailbox_until_attach,
91
96
  mirror_peer_message_to_leader, send_to_leader_receiver,
@@ -0,0 +1,169 @@
1
+ use super::*;
2
+ use crate::transport::test_support::OfflineTransport;
3
+ use std::path::Path;
4
+
5
+ fn pane(session: &str, tty: &str) -> PaneInfo {
6
+ PaneInfo {
7
+ pane_id: PaneId::new("%7"),
8
+ session: SessionName::new(session),
9
+ window_index: Some(0),
10
+ window_name: Some(WindowName::new("leader")),
11
+ pane_index: Some(0),
12
+ tty: Some(tty.to_string()),
13
+ current_command: Some("codex".to_string()),
14
+ current_path: None,
15
+ active: true,
16
+ pane_pid: Some(77),
17
+ leader_env: BTreeMap::new(),
18
+ }
19
+ }
20
+
21
+ fn receiver(socket: &str) -> serde_json::Value {
22
+ serde_json::json!({
23
+ "mode": "direct_tmux",
24
+ "status": "attached",
25
+ "pane_id": "%7",
26
+ "tmux_socket": socket,
27
+ "session_name": "old-session",
28
+ "window_name": "leader",
29
+ "pane_tty": "/dev/old",
30
+ "fingerprint": "old-session|0|0|/dev/old"
31
+ })
32
+ }
33
+
34
+ #[test]
35
+ fn live_direct_channel_accepts_session_and_tty_drift_as_diagnostics() {
36
+ let socket = "/tmp/team-agent-channel-a";
37
+ let workspace = Path::new("/tmp/team-agent-workspace-a");
38
+ let transport = OfflineTransport::new()
39
+ .with_tmux_endpoint(socket)
40
+ .with_targets(vec![pane("new-session", "/dev/new")]);
41
+
42
+ let LeaderChannelResolution::Live(LiveLeaderChannel::DirectTmux(channel)) =
43
+ resolve_live_leader_channel(workspace, &receiver(socket), &transport)
44
+ else {
45
+ panic!("same socket and pane must remain a live channel");
46
+ };
47
+
48
+ assert_eq!(channel.pane_id, "%7");
49
+ assert_eq!(channel.tmux_socket.as_deref(), Some(socket));
50
+ assert_eq!(
51
+ channel.metadata_drift,
52
+ vec!["session_name", "pane_tty", "fingerprint"]
53
+ );
54
+ }
55
+
56
+ #[test]
57
+ fn legacy_receiver_without_status_still_uses_live_channel_facts() {
58
+ let socket = "/tmp/team-agent-channel-a";
59
+ let workspace = Path::new("/tmp/team-agent-workspace-a");
60
+ let mut receiver = receiver(socket);
61
+ if let Some(receiver) = receiver.as_object_mut() {
62
+ receiver.remove("status");
63
+ }
64
+ let transport = OfflineTransport::new()
65
+ .with_tmux_endpoint(socket)
66
+ .with_targets(vec![pane("new-session", "/dev/new")]);
67
+
68
+ assert!(matches!(
69
+ resolve_live_leader_channel(workspace, &receiver, &transport),
70
+ LeaderChannelResolution::Live(LiveLeaderChannel::DirectTmux(_))
71
+ ));
72
+ }
73
+
74
+ #[test]
75
+ fn explicit_non_attached_status_still_refuses_before_liveness() {
76
+ let socket = "/tmp/team-agent-channel-a";
77
+ let workspace = Path::new("/tmp/team-agent-workspace-a");
78
+ let mut receiver = receiver(socket);
79
+ receiver["status"] = serde_json::json!("rebind_required");
80
+ let transport = OfflineTransport::new()
81
+ .with_tmux_endpoint(socket)
82
+ .with_targets(vec![pane("new-session", "/dev/new")]);
83
+
84
+ assert_eq!(
85
+ resolve_live_leader_channel(workspace, &receiver, &transport),
86
+ LeaderChannelResolution::Unbound(LeaderChannelUnbound::ReceiverNotAttached)
87
+ );
88
+ }
89
+
90
+ #[test]
91
+ fn legacy_receiver_without_status_still_rejects_a_foreign_workspace() {
92
+ let socket = "/tmp/team-agent-channel-a";
93
+ let workspace = Path::new("/tmp/team-agent-workspace-a");
94
+ let mut receiver = receiver(socket);
95
+ if let Some(receiver) = receiver.as_object_mut() {
96
+ receiver.remove("status");
97
+ }
98
+ let mut observed = pane("foreign-session", "/dev/foreign");
99
+ observed.current_path = Some(Path::new("/tmp/team-agent-workspace-b").to_path_buf());
100
+ let transport = OfflineTransport::new()
101
+ .with_tmux_endpoint(socket)
102
+ .with_targets(vec![observed]);
103
+
104
+ assert_eq!(
105
+ resolve_live_leader_channel(workspace, &receiver, &transport),
106
+ LeaderChannelResolution::Unbound(LeaderChannelUnbound::PaneWorkspaceMismatch)
107
+ );
108
+ }
109
+
110
+ #[test]
111
+ fn same_pane_id_on_foreign_socket_is_not_a_live_channel() {
112
+ let workspace = Path::new("/tmp/team-agent-workspace-a");
113
+ let transport = OfflineTransport::new()
114
+ .with_tmux_endpoint("/tmp/team-agent-channel-b")
115
+ .with_targets(vec![pane("old-session", "/dev/old")]);
116
+
117
+ assert_eq!(
118
+ resolve_live_leader_channel(
119
+ workspace,
120
+ &receiver("/tmp/team-agent-channel-a"),
121
+ &transport,
122
+ ),
123
+ LeaderChannelResolution::Unbound(LeaderChannelUnbound::EndpointMismatch)
124
+ );
125
+ }
126
+
127
+ #[test]
128
+ fn attached_record_without_live_pane_is_not_a_live_channel() {
129
+ let socket = "/tmp/team-agent-channel-a";
130
+ let workspace = Path::new("/tmp/team-agent-workspace-a");
131
+ let transport = OfflineTransport::new().with_tmux_endpoint(socket);
132
+
133
+ assert_eq!(
134
+ resolve_live_leader_channel(workspace, &receiver(socket), &transport),
135
+ LeaderChannelResolution::Unbound(LeaderChannelUnbound::PaneNotLive)
136
+ );
137
+ }
138
+
139
+ #[test]
140
+ fn live_direct_channel_accepts_a_pane_inside_the_target_workspace() {
141
+ let socket = "/tmp/team-agent-channel-a";
142
+ let workspace = Path::new("/tmp/team-agent-workspace-a");
143
+ let mut observed = pane("new-session", "/dev/new");
144
+ observed.current_path = Some(workspace.join("nested"));
145
+ let transport = OfflineTransport::new()
146
+ .with_tmux_endpoint(socket)
147
+ .with_targets(vec![observed]);
148
+
149
+ assert!(matches!(
150
+ resolve_live_leader_channel(workspace, &receiver(socket), &transport),
151
+ LeaderChannelResolution::Live(LiveLeaderChannel::DirectTmux(_))
152
+ ));
153
+ }
154
+
155
+ #[test]
156
+ fn same_socket_and_pane_in_a_foreign_workspace_is_not_a_live_leader() {
157
+ let socket = "/tmp/team-agent-channel-a";
158
+ let workspace = Path::new("/tmp/team-agent-workspace-a");
159
+ let mut observed = pane("foreign-session", "/dev/foreign");
160
+ observed.current_path = Some(Path::new("/tmp/team-agent-workspace-b").to_path_buf());
161
+ let transport = OfflineTransport::new()
162
+ .with_tmux_endpoint(socket)
163
+ .with_targets(vec![observed]);
164
+
165
+ assert_eq!(
166
+ resolve_live_leader_channel(workspace, &receiver(socket), &transport),
167
+ LeaderChannelResolution::Unbound(LeaderChannelUnbound::PaneWorkspaceMismatch)
168
+ );
169
+ }
@@ -292,6 +292,7 @@ impl Drop for EnvGuardMsg {
292
292
 
293
293
  mod basic;
294
294
  mod e23;
295
+ mod leader_channel;
295
296
  mod leader_inject_acceptance;
296
297
  mod main_preserved;
297
298
  mod runtime;
@@ -3221,30 +3221,41 @@ fn gate054_status_surfaces_pending_leader_notifications() {
3221
3221
 
3222
3222
  #[test]
3223
3223
  fn gate054_fallback_pane_grep_guard_no_cross_server_chain_when_socket_recorded() {
3224
- // Structural grep: the recorded-socket branch of the fallback inject
3225
- // chain must NOT compose the workspace or default tmux backends after
3226
- // the endpoint backend rejects. This is the byte-level fence against
3227
- // silent regression.
3224
+ // Structural grep: the recorded-socket transport set must contain only
3225
+ // the endpoint backend, while workspace/default transports remain in the
3226
+ // no-socket arm. Physical injection must consume the pane returned by the
3227
+ // shared resolver rather than rebuilding a target from state.
3228
3228
  let src = include_str!("../leader_receiver.rs");
3229
- let inject_start = src
3230
- .find("let inject_result: Result<InjectReport, TransportError>")
3231
- .expect("fallback inject chain must remain single-typed");
3232
- let inject_end = src[inject_start..]
3233
- .find("};\n\n match inject_result")
3234
- .map(|off| inject_start + off + 2)
3235
- .expect("fallback inject chain must terminate before match");
3236
- let chain = &src[inject_start..inject_end];
3237
- // The socket-recorded arm is the `Some(socket) => {..}` block. It must
3238
- // NOT contain a workspace or default fallback.
3239
- let some_arm_start = chain.find("Some(socket) => {").expect("Some(socket) arm");
3240
- let some_arm_end = chain[some_arm_start..]
3241
- .find("None => {")
3242
- .map(|off| some_arm_start + off)
3243
- .expect("None arm must follow");
3244
- let some_arm = &chain[some_arm_start..some_arm_end];
3229
+ let transports_start = src
3230
+ .find("let transports: Vec<Box<dyn Transport>>")
3231
+ .expect("fallback transport set must remain explicit");
3232
+ let transports_end = src[transports_start..]
3233
+ .find("let mut resolved")
3234
+ .map(|off| transports_start + off)
3235
+ .expect("transport selection must precede shared resolution");
3236
+ let transports = &src[transports_start..transports_end];
3237
+ let socket_arm_start = transports
3238
+ .find("Some(socket) =>")
3239
+ .expect("recorded-socket arm");
3240
+ let socket_arm_end = transports[socket_arm_start..]
3241
+ .find("None =>")
3242
+ .map(|off| socket_arm_start + off)
3243
+ .expect("no-socket arm must follow");
3244
+ let socket_arm = &transports[socket_arm_start..socket_arm_end];
3245
3245
  assert!(
3246
- !some_arm.contains("tmux_workspace_transport")
3247
- && !some_arm.contains("tmux_default_transport"),
3248
- "0.5.5 gate054: socket-recorded fallback arm must not compose workspace/default backends; arm={some_arm}"
3246
+ socket_arm.contains("tmux_endpoint_transport")
3247
+ && !socket_arm.contains("tmux_workspace_transport")
3248
+ && !socket_arm.contains("tmux_default_transport"),
3249
+ "0.5.5 gate054: socket-recorded fallback arm must remain endpoint-only; arm={socket_arm}"
3250
+ );
3251
+ let no_socket_arm = &transports[socket_arm_end..];
3252
+ assert!(
3253
+ no_socket_arm.contains("tmux_workspace_transport")
3254
+ && no_socket_arm.contains("tmux_default_transport"),
3255
+ "0.5.5 gate054: workspace/default transports belong only to the no-socket arm; arm={no_socket_arm}"
3256
+ );
3257
+ assert!(
3258
+ src.contains("let target = Target::Pane(PaneId::new(channel.pane_id));"),
3259
+ "fallback injection must consume the shared resolver's typed channel"
3249
3260
  );
3250
3261
  }
@@ -301,14 +301,14 @@ impl ProviderAdapter for BasicProviderAdapter {
301
301
  native_mcp_config: false,
302
302
  writes_global_settings: false,
303
303
  },
304
- // Copilot(C-4-1 cr verdict):resume 走 --resume <sid>;**无 fork** 旗标,
305
- // session-store 不支持 branched continuation caps.fork=false 显式拒。
304
+ // Copilot resume 走 --resume <sid>;store fork 在隔离 COPILOT_HOME
305
+ // 复制并重键 session backing 后以 --resume <new sid> 启动。
306
306
  // native_mcp_config=true(`--additional-mcp-config` 接 inline JSON 或 @file);
307
307
  // writes_global_settings=false(session 走 --session-id 预定 UUID,不污染
308
308
  // ~/.copilot/mcp-config.json,help 原文 "augments config for this session")。
309
309
  Provider::Copilot => ProviderCaps {
310
310
  resume: true,
311
- fork: false,
311
+ fork: true,
312
312
  native_mcp_config: true,
313
313
  writes_global_settings: false,
314
314
  },
@@ -829,10 +829,9 @@ impl ProviderAdapter for BasicProviderAdapter {
829
829
  argv.push("--fork-session".to_string());
830
830
  Ok(argv)
831
831
  }
832
- // C-4-2 cr verdict: copilot fork 旗标 + session-store 不支持 branched
833
- // continuation 显式 CapabilityUnsupported,**绝不** silent fallback
834
- // restart-from-scratch(MUST-NOT-13 诚实)。本分支理论上不可达(caps.fork=false
835
- // 已在 fork_with_context 入口拦截,line 582),保留作 totality 守护。
832
+ // Context-free adapter calls cannot materialize the Copilot store fork.
833
+ // The lifecycle path uses `fork_plan`, which receives the prepared isolated
834
+ // backing; never degrade this context-free call to a fresh spawn.
836
835
  Provider::Copilot => Err(ProviderError::CapabilityUnsupported(
837
836
  "copilot CLI 无 fork 旗标,session-store 不支持 branched continuation".to_string(),
838
837
  )),
@@ -858,11 +857,11 @@ impl ProviderAdapter for BasicProviderAdapter {
858
857
  provider_wire(self.provider)
859
858
  )));
860
859
  }
861
- let Some(session_id) = session_id else {
860
+ if session_id.is_none() {
862
861
  return Err(ProviderError::ResumeUnavailable(
863
862
  "fork requires session_id".to_string(),
864
863
  ));
865
- };
864
+ }
866
865
  let expected = next_session_token();
867
866
  let managed = ctx
868
867
  .profile_launch
@@ -881,11 +880,8 @@ impl ProviderAdapter for BasicProviderAdapter {
881
880
  managed,
882
881
  ctx.effort,
883
882
  )?;
884
- argv.push("--session-id".to_string());
885
- argv.push(expected.clone());
886
883
  argv.push("--resume".to_string());
887
- argv.push(session_id.as_str().to_string());
888
- argv.push("--fork-session".to_string());
884
+ argv.push(expected.clone());
889
885
  Ok(CommandPlan {
890
886
  argv,
891
887
  expected_session_id: Some(SessionId::new(expected)),
@@ -918,6 +914,51 @@ impl ProviderAdapter for BasicProviderAdapter {
918
914
  argv.push(session_id.as_str().to_string());
919
915
  Ok(CommandPlan::argv_only(argv))
920
916
  }
917
+ Provider::Copilot => {
918
+ if ctx.auth_mode == AuthMode::CompatibleApi {
919
+ return Err(ProviderError::CapabilityUnsupported(
920
+ "copilot does not support session fork in compatible_api mode".to_string(),
921
+ ));
922
+ }
923
+ let profile = ctx.profile_launch.ok_or_else(|| {
924
+ ProviderError::Command(
925
+ "copilot fork requires a prepared provider profile".to_string(),
926
+ )
927
+ })?;
928
+ let expected = profile
929
+ .env_overlay
930
+ .get("TEAM_AGENT_INTERNAL_COPILOT_FORK_SESSION_ID")
931
+ .filter(|value| !value.is_empty())
932
+ .map(SessionId::new)
933
+ .ok_or_else(|| {
934
+ ProviderError::Command(
935
+ "copilot fork session backing was not materialized".to_string(),
936
+ )
937
+ })?;
938
+ let root = profile
939
+ .env_overlay
940
+ .get("COPILOT_HOME")
941
+ .filter(|value| !value.is_empty())
942
+ .map(PathBuf::from)
943
+ .ok_or_else(|| {
944
+ ProviderError::Command("copilot fork root was not materialized".to_string())
945
+ })?;
946
+ let mut argv = copilot_base_command_resume(
947
+ ctx.auth_mode,
948
+ ctx.mcp_config,
949
+ ctx.system_prompt,
950
+ ctx.model,
951
+ ctx.tools,
952
+ );
953
+ argv.push("--resume".to_string());
954
+ argv.push(expected.as_str().to_string());
955
+ Ok(CommandPlan {
956
+ argv,
957
+ expected_session_id: Some(expected),
958
+ provider_projects_root: Some(root),
959
+ managed_mcp_config: profile.managed_mcp_config,
960
+ })
961
+ }
921
962
  _ => self
922
963
  .fork_with_context(
923
964
  session_id,
@@ -0,0 +1,122 @@
1
+ use std::path::{Path, PathBuf};
2
+
3
+ use crate::provider::{ProviderError, SessionId};
4
+
5
+ #[derive(Debug)]
6
+ pub(crate) struct ClaudeForkMaterialization {
7
+ path: PathBuf,
8
+ keep: bool,
9
+ }
10
+
11
+ impl ClaudeForkMaterialization {
12
+ pub(crate) fn path(&self) -> &Path {
13
+ &self.path
14
+ }
15
+
16
+ pub(crate) fn keep(&mut self) {
17
+ self.keep = true;
18
+ }
19
+ }
20
+
21
+ impl Drop for ClaudeForkMaterialization {
22
+ fn drop(&mut self) {
23
+ if !self.keep {
24
+ let _ = std::fs::remove_file(&self.path);
25
+ }
26
+ }
27
+ }
28
+
29
+ pub(crate) fn materialize_claude_fork(
30
+ source_path: &Path,
31
+ source_session_id: &SessionId,
32
+ target_session_id: &SessionId,
33
+ ) -> Result<ClaudeForkMaterialization, ProviderError> {
34
+ let parent = source_path.parent().ok_or_else(|| {
35
+ ProviderError::Io(format!(
36
+ "claude source backing has no parent: {}",
37
+ source_path.display()
38
+ ))
39
+ })?;
40
+ let target_path = parent.join(format!("{}.jsonl", target_session_id.as_str()));
41
+ if target_path.exists() {
42
+ return Err(ProviderError::Io(format!(
43
+ "claude fork destination already exists: {}",
44
+ target_path.display()
45
+ )));
46
+ }
47
+ let source = std::fs::read_to_string(source_path)
48
+ .map_err(|error| ProviderError::Io(error.to_string()))?;
49
+ let rewritten = source.replace(source_session_id.as_str(), target_session_id.as_str());
50
+ validate_jsonl(&rewritten)?;
51
+ let temp = parent.join(format!(
52
+ ".{}.tmp-{}",
53
+ target_session_id.as_str(),
54
+ std::process::id()
55
+ ));
56
+ let result = std::fs::write(&temp, rewritten)
57
+ .and_then(|_| std::fs::rename(&temp, &target_path))
58
+ .map_err(|error| ProviderError::Io(error.to_string()));
59
+ if let Err(error) = result {
60
+ let _ = std::fs::remove_file(&temp);
61
+ return Err(error);
62
+ }
63
+ Ok(ClaudeForkMaterialization {
64
+ path: target_path,
65
+ keep: false,
66
+ })
67
+ }
68
+
69
+ fn validate_jsonl(text: &str) -> Result<(), ProviderError> {
70
+ let mut rows = 0_usize;
71
+ for (index, line) in text.lines().enumerate() {
72
+ if line.trim().is_empty() {
73
+ continue;
74
+ }
75
+ serde_json::from_str::<serde_json::Value>(line).map_err(|error| {
76
+ ProviderError::Io(format!(
77
+ "claude fork source has invalid JSONL at line {}: {error}",
78
+ index + 1
79
+ ))
80
+ })?;
81
+ rows += 1;
82
+ }
83
+ if rows == 0 {
84
+ return Err(ProviderError::Io(
85
+ "claude fork source backing has no JSONL records".to_string(),
86
+ ));
87
+ }
88
+ Ok(())
89
+ }
90
+
91
+ #[cfg(test)]
92
+ mod tests {
93
+ use super::*;
94
+
95
+ #[test]
96
+ fn snapshot_rekeys_every_record_and_preserves_source() {
97
+ let dir = std::env::temp_dir().join(format!("ta-claude-fork-copy-{}", std::process::id()));
98
+ let _ = std::fs::remove_dir_all(&dir);
99
+ std::fs::create_dir_all(&dir).unwrap();
100
+ let source_id = SessionId::new("11111111-1111-4111-8111-111111111111");
101
+ let target_id = SessionId::new("22222222-2222-4222-8222-222222222222");
102
+ let source_path = dir.join(format!("{}.jsonl", source_id.as_str()));
103
+ let source = format!(
104
+ "{{\"sessionId\":\"{}\"}}\n{{\"payload\":{{\"session_id\":\"{}\"}}}}\n",
105
+ source_id.as_str(),
106
+ source_id.as_str()
107
+ );
108
+ std::fs::write(&source_path, &source).unwrap();
109
+
110
+ let mut materialized =
111
+ materialize_claude_fork(&source_path, &source_id, &target_id).unwrap();
112
+ let target_path = dir.join(format!("{}.jsonl", target_id.as_str()));
113
+ let target = std::fs::read_to_string(&target_path).unwrap();
114
+ assert!(!target.contains(source_id.as_str()));
115
+ assert!(target.contains(target_id.as_str()));
116
+ assert_eq!(std::fs::read_to_string(&source_path).unwrap(), source);
117
+ materialized.keep();
118
+ drop(materialized);
119
+ assert!(target_path.is_file());
120
+ let _ = std::fs::remove_dir_all(&dir);
121
+ }
122
+ }