@team-agent/installer 0.5.77 → 0.5.80

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 (95) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +161 -29
  4. package/crates/team-agent/src/cli/diagnose.rs +333 -68
  5. package/crates/team-agent/src/cli/emit.rs +24 -8
  6. package/crates/team-agent/src/cli/mod.rs +543 -98
  7. package/crates/team-agent/src/cli/models.rs +393 -32
  8. package/crates/team-agent/src/cli/named_address.rs +19 -5
  9. package/crates/team-agent/src/cli/profile.rs +35 -4
  10. package/crates/team-agent/src/cli/spec.rs +7 -2
  11. package/crates/team-agent/src/cli/status_port/compact.rs +12 -8
  12. package/crates/team-agent/src/cli/status_port/runtime.rs +3 -1
  13. package/crates/team-agent/src/cli/status_port/snapshot.rs +15 -12
  14. package/crates/team-agent/src/cli/status_port/store.rs +1 -1
  15. package/crates/team-agent/src/cli/status_port/tests.rs +37 -1
  16. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +62 -8
  17. package/crates/team-agent/src/cli/tests/named_address.rs +70 -0
  18. package/crates/team-agent/src/cli/tests/verb_profile.rs +81 -6
  19. package/crates/team-agent/src/cli/types.rs +1 -0
  20. package/crates/team-agent/src/coordinator/steps/abnormal.rs +122 -2
  21. package/crates/team-agent/src/leader/lease.rs +492 -33
  22. package/crates/team-agent/src/leader/owner_bind.rs +40 -12
  23. package/crates/team-agent/src/leader/rediscover/tests.rs +65 -0
  24. package/crates/team-agent/src/leader/rediscover.rs +36 -6
  25. package/crates/team-agent/src/leader/start.rs +197 -88
  26. package/crates/team-agent/src/leader/tests/wake_start_owner.rs +15 -9
  27. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +28 -227
  28. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +0 -18
  29. package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +94 -227
  30. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +59 -10
  31. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +86 -23
  32. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +1449 -282
  33. package/crates/team-agent/src/lifecycle/launch/readiness.rs +349 -4
  34. package/crates/team-agent/src/lifecycle/launch/spawn.rs +11 -11
  35. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +35 -3
  36. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +16 -0
  37. package/crates/team-agent/src/lifecycle/launch.rs +30 -7
  38. package/crates/team-agent/src/lifecycle/profile_launch.rs +37 -3
  39. package/crates/team-agent/src/lifecycle/restart/agent.rs +112 -100
  40. package/crates/team-agent/src/lifecycle/restart/common.rs +82 -52
  41. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +996 -66
  42. package/crates/team-agent/src/lifecycle/restart.rs +2 -2
  43. package/crates/team-agent/src/lifecycle/tests/acceptance_b_batch_red.rs +3 -0
  44. package/crates/team-agent/src/lifecycle/tests/behavioral_diff_264_red.rs +15 -0
  45. package/crates/team-agent/src/lifecycle/tests/claude_compatible_config_red.rs +5 -0
  46. package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +11 -13
  47. package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +119 -4
  48. package/crates/team-agent/src/lifecycle/tests/communication_mode_runtime_contract_red.rs +12 -0
  49. package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +22 -0
  50. package/crates/team-agent/src/lifecycle/tests/core_034_real_red.rs +12 -0
  51. package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +265 -198
  52. package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +14 -30
  53. package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +3 -0
  54. package/crates/team-agent/src/lifecycle/tests/fixtures/pi_list_models_spaced_ids.stdout.txt +9 -0
  55. package/crates/team-agent/src/lifecycle/tests/g1_silent_faces.rs +2 -5
  56. package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +10 -0
  57. package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +58 -35
  58. package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +10 -0
  59. package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +9 -0
  60. package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +3 -2
  61. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +0 -168
  62. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +145 -212
  63. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +16 -2
  64. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +54 -9
  65. package/crates/team-agent/src/lifecycle/tests/pi_catalog_red.rs +71 -0
  66. package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +78 -0
  67. package/crates/team-agent/src/lifecycle/tests/team_in_team_identity_scope_red.rs +3 -0
  68. package/crates/team-agent/src/lifecycle/tests/test_isolation_escape_contract.rs +51 -10
  69. package/crates/team-agent/src/lifecycle/tests/verify_rs031_window_consistency_red.rs +50 -7
  70. package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +137 -0
  71. package/crates/team-agent/src/lifecycle/types.rs +8 -0
  72. package/crates/team-agent/src/mcp_server/wire.rs +140 -11
  73. package/crates/team-agent/src/messaging/delivery.rs +221 -80
  74. package/crates/team-agent/src/messaging/leader_channel.rs +56 -0
  75. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +127 -0
  76. package/crates/team-agent/src/messaging/tests/runtime.rs +10 -2
  77. package/crates/team-agent/src/provider/adapters/cursor_agent.rs +2 -5
  78. package/crates/team-agent/src/provider/session_scan/common/tests.rs +204 -0
  79. package/crates/team-agent/src/provider/session_scan/common.rs +24 -2
  80. package/crates/team-agent/src/state/abnormal_watch/tests/mod.rs +248 -0
  81. package/crates/team-agent/src/state/abnormal_watch.rs +144 -0
  82. package/crates/team-agent/src/state/mod.rs +1 -0
  83. package/crates/team-agent/src/state/persist.rs +102 -4
  84. package/crates/team-agent/src/state/projection.rs +7 -1
  85. package/crates/team-agent/src/state/repository.rs +29 -0
  86. package/crates/team-agent/src/test_support/models_timeout_fixture.rs +43 -21
  87. package/crates/team-agent/src/tmux_backend/tests.rs +149 -4
  88. package/crates/team-agent/src/tmux_backend.rs +239 -127
  89. package/crates/team-agent/src/transport.rs +78 -0
  90. package/crates/team-agent/src/transport_factory.rs +53 -0
  91. package/package.json +4 -5
  92. package/skills/team-agent/SKILL.md +45 -17
  93. package/skills/team-agent/command-coverage.json +4 -29
  94. package/npm/check-skill-links.mjs +0 -88
  95. package/skills/team-agent/references/team-agent-operator.md +0 -672
@@ -185,7 +185,7 @@ fn run_stdio_loop<R: BufRead, W: Write>(
185
185
  let mut report = ServerRunReport::default();
186
186
  let marker = McpServerLifecycleMarker::from_env(workspace);
187
187
  marker.write_started();
188
- let result = run_stdio_loop_inner(&tools, reader, &mut writer, &mut report);
188
+ let result = run_stdio_loop_inner(&tools, reader, &mut writer, &mut report, &marker);
189
189
  match &result {
190
190
  Ok(_) => marker.write_exit("stdin_eof", None),
191
191
  Err(error) => marker.write_exit("fatal_error", Some(&error.to_string())),
@@ -198,16 +198,20 @@ fn run_stdio_loop_inner<R: BufRead, W: Write>(
198
198
  reader: R,
199
199
  writer: &mut W,
200
200
  report: &mut ServerRunReport,
201
+ marker: &McpServerLifecycleMarker,
201
202
  ) -> Result<ServerRunReport, McpError> {
202
203
  for line in reader.lines() {
203
204
  let line = line?;
204
205
  report.requests_read = report.requests_read.saturating_add(1);
205
- let frame = handle_stdin_line(tools, &line, report)?;
206
+ let (frame, tools_list_success) = handle_stdin_line(tools, &line, report)?;
206
207
  if let Some(value) = frame {
207
208
  let value = crate::redaction::redact_external_value(&value);
208
209
  serde_json::to_writer(&mut *writer, &value)?;
209
210
  writer.write_all(b"\n")?;
210
211
  writer.flush()?;
212
+ if tools_list_success {
213
+ marker.write_tools_list_response_written();
214
+ }
211
215
  report.responses_written = report.responses_written.saturating_add(1);
212
216
  }
213
217
  }
@@ -219,29 +223,34 @@ fn handle_stdin_line(
219
223
  tools: &TeamOrchestratorTools,
220
224
  line: &str,
221
225
  report: &mut ServerRunReport,
222
- ) -> Result<Option<Value>, McpError> {
226
+ ) -> Result<(Option<Value>, bool), McpError> {
223
227
  let request: Value = match serde_json::from_str(line) {
224
228
  Ok(value) => value,
225
229
  Err(err) => {
226
230
  report.error_frames = report.error_frames.saturating_add(1);
227
- return Ok(Some(error_response_value(
228
- RpcId::Null,
229
- -32700,
230
- format!("parse error: {err}"),
231
- )));
231
+ return Ok((
232
+ Some(error_response_value(
233
+ RpcId::Null,
234
+ -32700,
235
+ format!("parse error: {err}"),
236
+ )),
237
+ false,
238
+ ));
232
239
  }
233
240
  };
234
241
  if request.get("jsonrpc").and_then(Value::as_str) == Some("2.0") {
242
+ let is_tools_list = request.get("method").and_then(Value::as_str) == Some("tools/list");
235
243
  match handle_mcp(tools, &request)? {
236
244
  Some(response) => {
245
+ let tools_list_success = is_tools_list && response.error.is_none();
237
246
  if response.error.is_some() {
238
247
  report.error_frames = report.error_frames.saturating_add(1);
239
248
  }
240
- Ok(Some(serde_json::to_value(response)?))
249
+ Ok((Some(serde_json::to_value(response)?), tools_list_success))
241
250
  }
242
251
  None => {
243
252
  report.notifications_skipped = report.notifications_skipped.saturating_add(1);
244
- Ok(None)
253
+ Ok((None, false))
245
254
  }
246
255
  }
247
256
  } else {
@@ -252,7 +261,7 @@ fn handle_stdin_line(
252
261
  err.to_envelope()
253
262
  }
254
263
  };
255
- Ok(Some(value))
264
+ Ok((Some(value), false))
256
265
  }
257
266
  }
258
267
 
@@ -301,6 +310,23 @@ impl McpServerLifecycleMarker {
301
310
  self.write("mcp.server_started", None, None);
302
311
  }
303
312
 
313
+ fn write_tools_list_response_written(&self) {
314
+ let _ = EventLog::new(&self.workspace).write(
315
+ "mcp.tools_list_response_written",
316
+ serde_json::json!({
317
+ "method": "tools/list",
318
+ "success": true,
319
+ "agent_id": self.agent_id.as_deref(),
320
+ "owner_team_id": self.owner_team_id.as_deref(),
321
+ "workspace": self.workspace.display().to_string(),
322
+ "pid": self.pid,
323
+ "ppid": self.ppid,
324
+ "spawn_epoch": null,
325
+ "spawn_epoch_source": "unavailable",
326
+ }),
327
+ );
328
+ }
329
+
304
330
  fn write_exit(&self, reason: &str, error: Option<&str>) {
305
331
  self.write("mcp.server_exit", Some(reason), error);
306
332
  }
@@ -798,6 +824,109 @@ mod e23_lifecycle_marker_tests {
798
824
  path
799
825
  }
800
826
 
827
+ #[test]
828
+ fn stdio_loop_logs_tools_list_after_flush_once_per_response() {
829
+ let ws = marker_ws("tools-list-success");
830
+ let input = std::io::Cursor::new(
831
+ br#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}
832
+ {"jsonrpc":"2.0","id":2,"method":"tools/list"}
833
+ "#.to_vec(),
834
+ );
835
+ let mut output = Vec::new();
836
+
837
+ let report = run_stdio_loop(&ws, input, &mut output).unwrap();
838
+
839
+ assert_eq!(report.responses_written, 2);
840
+ assert_eq!(output.iter().filter(|byte| **byte == b'\n').count(), 2);
841
+ let events = EventLog::new(&ws).tail(0).unwrap();
842
+ let tool_events: Vec<_> = events
843
+ .iter()
844
+ .filter(|event| event["event"] == serde_json::json!("mcp.tools_list_response_written"))
845
+ .collect();
846
+ assert_eq!(tool_events.len(), 2, "one event per successful tools/list response");
847
+ for event in tool_events {
848
+ assert_eq!(event["method"], serde_json::json!("tools/list"));
849
+ assert_eq!(event["success"], serde_json::json!(true));
850
+ assert_eq!(event["workspace"], serde_json::json!(ws.display().to_string()));
851
+ assert_eq!(event["pid"], serde_json::json!(std::process::id()));
852
+ assert_eq!(event["spawn_epoch"], serde_json::Value::Null);
853
+ }
854
+ let _ = std::fs::remove_dir_all(&ws);
855
+ }
856
+
857
+ #[test]
858
+ fn stdio_loop_does_not_log_tools_list_when_write_fails() {
859
+ struct FailingWriter;
860
+ impl Write for FailingWriter {
861
+ fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
862
+ Err(std::io::Error::other("simulated stdout write failure"))
863
+ }
864
+
865
+ fn flush(&mut self) -> std::io::Result<()> {
866
+ Ok(())
867
+ }
868
+ }
869
+
870
+ let ws = marker_ws("tools-list-write-fail");
871
+ let input = std::io::Cursor::new(
872
+ br#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#.to_vec(),
873
+ );
874
+ let error = run_stdio_loop(&ws, input, FailingWriter).expect_err("write must fail");
875
+ assert!(error.to_string().contains("simulated stdout write failure"));
876
+ let events = EventLog::new(&ws).tail(0).unwrap();
877
+ assert!(!events
878
+ .iter()
879
+ .any(|event| event["event"] == serde_json::json!("mcp.tools_list_response_written")));
880
+ let _ = std::fs::remove_dir_all(&ws);
881
+ }
882
+
883
+ #[test]
884
+ fn stdio_loop_does_not_log_tools_list_when_flush_fails() {
885
+ struct FlushFailWriter(Vec<u8>);
886
+ impl Write for FlushFailWriter {
887
+ fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
888
+ self.0.extend_from_slice(buf);
889
+ Ok(buf.len())
890
+ }
891
+
892
+ fn flush(&mut self) -> std::io::Result<()> {
893
+ Err(std::io::Error::other("simulated stdout flush failure"))
894
+ }
895
+ }
896
+
897
+ let ws = marker_ws("tools-list-flush-fail");
898
+ let input = std::io::Cursor::new(
899
+ br#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#.to_vec(),
900
+ );
901
+ let error = run_stdio_loop(&ws, input, FlushFailWriter(Vec::new()))
902
+ .expect_err("flush must fail");
903
+ assert!(error.to_string().contains("simulated stdout flush failure"));
904
+ let events = EventLog::new(&ws).tail(0).unwrap();
905
+ assert!(!events
906
+ .iter()
907
+ .any(|event| event["event"] == serde_json::json!("mcp.tools_list_response_written")));
908
+ let _ = std::fs::remove_dir_all(&ws);
909
+ }
910
+
911
+ #[test]
912
+ fn stdio_loop_does_not_log_tools_list_for_initialize_or_notification() {
913
+ let ws = marker_ws("tools-list-not-applicable");
914
+ let input = std::io::Cursor::new(
915
+ br#"{"jsonrpc":"2.0","id":1,"method":"initialize"}
916
+ {"jsonrpc":"2.0","method":"notifications/initialized"}
917
+ "#.to_vec(),
918
+ );
919
+ let mut output = Vec::new();
920
+
921
+ run_stdio_loop(&ws, input, &mut output).unwrap();
922
+
923
+ let events = EventLog::new(&ws).tail(0).unwrap();
924
+ assert!(!events
925
+ .iter()
926
+ .any(|event| event["event"] == serde_json::json!("mcp.tools_list_response_written")));
927
+ let _ = std::fs::remove_dir_all(&ws);
928
+ }
929
+
801
930
  #[test]
802
931
  fn stdio_loop_writes_started_and_stdin_eof_exit_markers() {
803
932
  let ws = marker_ws("eof");
@@ -31,9 +31,10 @@ use crate::provider::wire::{
31
31
  is_claude_family, parse_canonical_provider, parse_provider, provider_wire,
32
32
  };
33
33
  use crate::transport::{
34
- submit_verification_wire, turn_verification_wire, CaptureRange, CaptureSampleOutcome,
35
- InjectPayload, InjectReport, InjectVerification, InputSurfaceProbe, Key, PaneId, PaneInfo,
36
- QueryOutcome, SessionName, SubmitObserver, SubmitVerification, Target, Transport, WindowName,
34
+ submit_verification_wire, turn_verification_wire, BusySignalKind, CaptureRange,
35
+ CaptureSampleOutcome, InjectPayload, InjectReport, InjectVerification, InputSurfaceProbe, Key,
36
+ PaneId, PaneInfo, QueryOutcome, SessionName, SubmitConsumptionReason, SubmitObserver,
37
+ SubmitVerification, Target, Transport, WindowName,
37
38
  };
38
39
 
39
40
  use super::helpers::{message_exists, MessageStatusShadow};
@@ -219,31 +220,128 @@ pub fn deliver_pending_message(
219
220
  turn_verification: None,
220
221
  });
221
222
  };
222
- let mut canonical_owner_team_id = message.owner_team_id.clone();
223
- let scoped_state;
224
- let state = match message.owner_team_id.as_deref() {
225
- Some(team) if !team.is_empty() => {
226
- match project_state_for_owner_team(
227
- workspace,
228
- team,
223
+ match prepare_message_state(
224
+ workspace, store, message_id, event_log, state, &message, false,
225
+ )? {
226
+ Ok(prepared) => deliver_prepared_message(
227
+ workspace, store, transport, message_id, event_log, state, message, prepared,
228
+ ),
229
+ Err(outcome) => Ok(outcome),
230
+ }
231
+ }
232
+
233
+ // Thread-local observation only; no production cache or scheduling hook.
234
+ #[cfg(test)]
235
+ pub(super) mod preparation_probe {
236
+ std::thread_local! {
237
+ pub static COUNTS: std::cell::Cell<(usize, usize, usize)> = const { std::cell::Cell::new((0, 0, 0)) };
238
+ pub static BEFORE_CLAIM: std::cell::RefCell<Option<Box<dyn FnOnce()>>> = const { std::cell::RefCell::new(None) };
239
+ }
240
+ }
241
+
242
+ struct PreparedMessageState {
243
+ scoped: Option<serde_json::Value>,
244
+ canonical_owner_team_id: Option<String>,
245
+ }
246
+
247
+ fn prepare_message_state(
248
+ workspace: &Path,
249
+ store: &MessageStore,
250
+ message_id: &str,
251
+ event_log: &EventLog,
252
+ state: &serde_json::Value,
253
+ message: &PendingMessage,
254
+ batch: bool,
255
+ ) -> Result<Result<PreparedMessageState, DeliveryOutcome>, MessagingError> {
256
+ #[cfg(test)]
257
+ preparation_probe::COUNTS.with(|counts| {
258
+ let (prepare, project, claim) = counts.get();
259
+ counts.set((prepare + 1, project, claim));
260
+ });
261
+ let mut prepared = PreparedMessageState {
262
+ scoped: None,
263
+ canonical_owner_team_id: message.owner_team_id.clone(),
264
+ };
265
+ if let Some(team) = message
266
+ .owner_team_id
267
+ .as_deref()
268
+ .filter(|team| !team.is_empty())
269
+ {
270
+ match project_state_for_owner_team(
271
+ workspace,
272
+ team,
273
+ state,
274
+ Some(store),
275
+ Some(message_id),
276
+ Some(event_log),
277
+ )? {
278
+ OwnerTeamProjection::Projected {
229
279
  state,
230
- Some(store),
231
- Some(message_id),
232
- Some(event_log),
233
- )? {
234
- OwnerTeamProjection::Projected {
235
- state,
236
- canonical_team,
237
- } => {
238
- canonical_owner_team_id = Some(canonical_team);
239
- scoped_state = state;
240
- &scoped_state
280
+ canonical_team,
281
+ } => {
282
+ prepared.scoped = Some(state);
283
+ prepared.canonical_owner_team_id = Some(canonical_team);
284
+ }
285
+ OwnerTeamProjection::Refused(outcome) => {
286
+ if batch {
287
+ let _ = event_log.write(
288
+ "delivery.projection_refused",
289
+ serde_json::json!({
290
+ "message_id": message_id,
291
+ "owner_team_id": team,
292
+ "status": super::helpers::status_wire(outcome.status),
293
+ "verification": outcome.verification,
294
+ "reason": format!("{:?}", outcome.reason),
295
+ }),
296
+ );
241
297
  }
242
- OwnerTeamProjection::Refused(outcome) => return Ok(outcome),
298
+ return Ok(Err(outcome));
243
299
  }
244
300
  }
245
- _ => state,
246
- };
301
+ }
302
+ if batch
303
+ && recipient_is_busy(
304
+ prepared.scoped.as_ref().unwrap_or(state),
305
+ &message.recipient,
306
+ )
307
+ {
308
+ event_log.write(
309
+ "send.deferred_busy",
310
+ serde_json::json!({
311
+ "message_id": message_id,
312
+ "sender": message.sender,
313
+ "recipient": message.recipient,
314
+ "reason": "recipient_busy",
315
+ }),
316
+ )?;
317
+ return Ok(Err(DeliveryOutcome {
318
+ ok: false,
319
+ status: DeliveryStatus::Queued,
320
+ message_status: MessageStatusShadow(message.status.clone()),
321
+ message_id: Some(message_id.to_string()),
322
+ verification: None,
323
+ stage: None,
324
+ reason: None,
325
+ channel: None,
326
+ ack_forced_off: false,
327
+ turn_verification: None,
328
+ }));
329
+ }
330
+ Ok(Ok(prepared))
331
+ }
332
+
333
+ fn deliver_prepared_message(
334
+ workspace: &Path,
335
+ store: &MessageStore,
336
+ transport: &dyn Transport,
337
+ message_id: &str,
338
+ event_log: &EventLog,
339
+ fallback: &serde_json::Value,
340
+ message: PendingMessage,
341
+ prepared: PreparedMessageState,
342
+ ) -> Result<DeliveryOutcome, MessagingError> {
343
+ let canonical_owner_team_id = prepared.canonical_owner_team_id;
344
+ let state = prepared.scoped.as_ref().unwrap_or(fallback);
247
345
  if message.recipient == "leader"
248
346
  && matches!(
249
347
  message.status.as_str(),
@@ -290,6 +388,18 @@ pub fn deliver_pending_message(
290
388
  }),
291
389
  )?;
292
390
  }
391
+ #[cfg(test)]
392
+ {
393
+ preparation_probe::BEFORE_CLAIM.with(|hook| {
394
+ if let Some(hook) = hook.borrow_mut().take() {
395
+ hook();
396
+ }
397
+ });
398
+ preparation_probe::COUNTS.with(|counts| {
399
+ let (prepare, project, claim) = counts.get();
400
+ counts.set((prepare, project, claim + 1));
401
+ });
402
+ }
293
403
  let attempt = if store.claim_for_delivery(message_id)? {
294
404
  message.delivery_attempts.saturating_add(1)
295
405
  } else {
@@ -572,11 +682,11 @@ pub fn deliver_pending_message(
572
682
  canonical_owner_team_id.as_deref(),
573
683
  resolved.metadata.as_ref(),
574
684
  );
575
- let recipient_single_enter = recipient_requires_single_enter(state, &message.recipient);
685
+ let recipient_cursor = recipient_is_cursor_agent(state, &message.recipient);
576
686
  let inject_attempt = crate::tmux_backend::with_paste_to_submit_floor(
577
687
  paste_to_submit_floor_for_recipient(state, &message.recipient),
578
688
  || {
579
- crate::tmux_backend::with_cursor_single_enter(recipient_single_enter, || {
689
+ crate::tmux_backend::with_cursor_single_enter(recipient_cursor, || {
580
690
  transport.inject_with_submit_observer(
581
691
  &target,
582
692
  &payload,
@@ -592,9 +702,7 @@ pub fn deliver_pending_message(
592
702
  // grok 忙时第一次回车只入显式队列。默认 send-now:只重按回车顶出去。
593
703
  // 无 `Enter:send now` 时零次额外按键,claude/codex 行为不变。
594
704
  // cursor:第二下 Enter 打断进行中回合,不走 flush。
595
- if !crate::provider::submit_now::keep_provider_queue_requested()
596
- && recipient_allows_explicit_queue_flush(state, &message.recipient)
597
- {
705
+ if !crate::provider::submit_now::keep_provider_queue_requested() && !recipient_cursor {
598
706
  match crate::provider::submit_now::flush_explicit_queue(transport, &target) {
599
707
  Ok(flush) if flush.extra_enters > 0 => {
600
708
  let _ = event_log.write(
@@ -1947,10 +2055,7 @@ fn merge_inject_readback_fields(
1947
2055
  };
1948
2056
  let inject_pane = target.and_then(inject_pane_id);
1949
2057
  if let Some(pane) = inject_pane {
1950
- obj.insert(
1951
- "inject_target_pane_id".to_string(),
1952
- serde_json::json!(pane),
1953
- );
2058
+ obj.insert("inject_target_pane_id".to_string(), serde_json::json!(pane));
1954
2059
  }
1955
2060
  let resolved_pane = metadata.map(|meta| meta.target_pane_id.as_str());
1956
2061
  obj.insert(
@@ -1999,6 +2104,36 @@ fn merge_inject_readback_fields(
1999
2104
  .map(|d| d.last_capture_outcome.as_str())
2000
2105
  .unwrap_or(CaptureSampleOutcome::NotAttempted.as_str())),
2001
2106
  );
2107
+ obj.insert(
2108
+ "consumption_reason".to_string(),
2109
+ serde_json::json!(diag
2110
+ .map(|d| d.consumption_reason.as_str())
2111
+ .unwrap_or("unknown")),
2112
+ );
2113
+ obj.insert(
2114
+ "before_busy_signal_kind".to_string(),
2115
+ serde_json::json!(diag.and_then(|d| d.before_busy_signal_kind.map(|kind| kind.as_str()))),
2116
+ );
2117
+ obj.insert(
2118
+ "busy_signal_kind".to_string(),
2119
+ serde_json::json!(diag.and_then(|d| d.busy_signal_kind.map(|kind| kind.as_str()))),
2120
+ );
2121
+ obj.insert(
2122
+ "busy_line_from_bottom".to_string(),
2123
+ serde_json::json!(diag.and_then(|d| d.busy_line_from_bottom)),
2124
+ );
2125
+ obj.insert(
2126
+ "current_marker_in_bottom_15".to_string(),
2127
+ serde_json::json!(diag.and_then(|d| d.current_marker_in_bottom_15)),
2128
+ );
2129
+ obj.insert(
2130
+ "paste_identity_in_composer".to_string(),
2131
+ serde_json::json!(diag.and_then(|d| d.paste_identity_in_composer)),
2132
+ );
2133
+ obj.insert(
2134
+ "consumption_from_capture_result".to_string(),
2135
+ serde_json::json!(diag.and_then(|d| d.consumption_from_capture_result)),
2136
+ );
2002
2137
  obj.insert(
2003
2138
  "pane_command_basename".to_string(),
2004
2139
  diag.and_then(|d| d.pane_command_basename.as_deref())
@@ -2161,59 +2296,30 @@ pub fn deliver_pending_messages(
2161
2296
  };
2162
2297
  let mut delivered = Vec::new();
2163
2298
  for message_id in message_ids {
2164
- if let Some(message) = message_for_delivery(&store, &message_id)? {
2165
- let scoped_state;
2166
- let state = match message.owner_team_id.as_deref() {
2167
- Some(team) if !team.is_empty() => {
2168
- match project_state_for_owner_team(
2169
- workspace,
2170
- team,
2171
- state,
2172
- Some(&store),
2173
- Some(&message_id),
2174
- Some(event_log),
2175
- )? {
2176
- OwnerTeamProjection::Projected { state, .. } => {
2177
- scoped_state = state;
2178
- &scoped_state
2179
- }
2180
- OwnerTeamProjection::Refused(outcome) => {
2181
- let _ = event_log.write(
2182
- "delivery.projection_refused",
2183
- serde_json::json!({
2184
- "message_id": message_id.as_str(),
2185
- "owner_team_id": team,
2186
- "status": super::helpers::status_wire(outcome.status),
2187
- "verification": outcome.verification,
2188
- "reason": format!("{:?}", outcome.reason),
2189
- }),
2190
- );
2191
- continue;
2192
- }
2193
- }
2194
- }
2195
- _ => state,
2196
- };
2197
- if recipient_is_busy(state, &message.recipient) {
2198
- event_log.write(
2199
- "send.deferred_busy",
2200
- serde_json::json!({
2201
- "message_id": message_id,
2202
- "sender": message.sender,
2203
- "recipient": message.recipient,
2204
- "reason": "recipient_busy",
2205
- }),
2206
- )?;
2207
- continue;
2208
- }
2209
- }
2210
- let outcome = match deliver_pending_message(
2299
+ let Some(message) = message_for_delivery(&store, &message_id)? else {
2300
+ continue;
2301
+ };
2302
+ let prepared = match prepare_message_state(
2303
+ workspace,
2304
+ &store,
2305
+ &message_id,
2306
+ event_log,
2307
+ state,
2308
+ &message,
2309
+ true,
2310
+ )? {
2311
+ Ok(prepared) => prepared,
2312
+ Err(_) => continue,
2313
+ };
2314
+ let outcome = match deliver_prepared_message(
2211
2315
  workspace,
2212
2316
  &store,
2213
2317
  transport,
2214
2318
  &message_id,
2215
2319
  event_log,
2216
2320
  state,
2321
+ message,
2322
+ prepared,
2217
2323
  ) {
2218
2324
  Ok(outcome) => outcome,
2219
2325
  Err(error) => {
@@ -2357,6 +2463,13 @@ fn recipient_provider(state: &serde_json::Value, recipient: &str) -> Option<Prov
2357
2463
  .and_then(parse_canonical_provider)
2358
2464
  }
2359
2465
 
2466
+ fn recipient_is_cursor_agent(state: &serde_json::Value, recipient: &str) -> bool {
2467
+ matches!(
2468
+ recipient_provider(state, recipient),
2469
+ Some(Provider::CursorAgent)
2470
+ )
2471
+ }
2472
+
2360
2473
  /// ---
2361
2474
  /// purpose: paste→Enter 地板按收件人 provider 选择
2362
2475
  /// contract: CursorAgent 与 Grok 为 1s;其余(含 claude)为 ZERO
@@ -2927,6 +3040,11 @@ fn project_state_for_owner_team(
2927
3040
  message_id: Option<&str>,
2928
3041
  event_log: Option<&EventLog>,
2929
3042
  ) -> Result<OwnerTeamProjection, MessagingError> {
3043
+ #[cfg(test)]
3044
+ preparation_probe::COUNTS.with(|counts| {
3045
+ let (prepare, project, claim) = counts.get();
3046
+ counts.set((prepare, project + 1, claim));
3047
+ });
2930
3048
  let raw = crate::state::persist::load_runtime_state(workspace)?;
2931
3049
  let fallback_has_teams = fallback
2932
3050
  .get("teams")
@@ -3391,6 +3509,13 @@ mod paste_floor_tests {
3391
3509
  turn_verification: crate::transport::TurnVerification::NotYetObserved,
3392
3510
  attempts: 1,
3393
3511
  submit_diagnostics: Some(crate::transport::SubmitDiagnostics {
3512
+ consumption_reason: SubmitConsumptionReason::Unverified,
3513
+ before_busy_signal_kind: Some(BusySignalKind::Working),
3514
+ busy_signal_kind: Some(BusySignalKind::SpinnerGlyph),
3515
+ busy_line_from_bottom: Some(2),
3516
+ current_marker_in_bottom_15: Some(false),
3517
+ paste_identity_in_composer: None,
3518
+ consumption_from_capture_result: Some(false),
3394
3519
  last_capture_outcome: CaptureSampleOutcome::Failed,
3395
3520
  capture_err_count: 1,
3396
3521
  capture_sample_count: 1,
@@ -3419,6 +3544,22 @@ mod paste_floor_tests {
3419
3544
  None,
3420
3545
  );
3421
3546
  assert_eq!(event["last_capture_outcome"], serde_json::json!("failed"));
3547
+ assert_eq!(event["consumption_reason"], serde_json::json!("unverified"));
3548
+ assert_eq!(
3549
+ event["before_busy_signal_kind"],
3550
+ serde_json::json!("working")
3551
+ );
3552
+ assert_eq!(event["busy_signal_kind"], serde_json::json!("spinner_glyph"));
3553
+ assert_eq!(event["busy_line_from_bottom"], serde_json::json!(2));
3554
+ assert_eq!(
3555
+ event["current_marker_in_bottom_15"],
3556
+ serde_json::json!(false)
3557
+ );
3558
+ assert_eq!(event["paste_identity_in_composer"], serde_json::Value::Null);
3559
+ assert_eq!(
3560
+ event["consumption_from_capture_result"],
3561
+ serde_json::json!(false)
3562
+ );
3422
3563
  assert_eq!(event["token_seen_after_paste"], serde_json::Value::Null);
3423
3564
  assert_eq!(event["pane_command_basename"], serde_json::json!("grok"));
3424
3565
  assert_eq!(event["input_surface"], serde_json::json!("input"));
@@ -229,3 +229,59 @@ fn string_field<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
229
229
  .and_then(Value::as_str)
230
230
  .filter(|value| !value.is_empty())
231
231
  }
232
+
233
+ #[cfg(test)]
234
+ mod claim_rework_live_tests {
235
+ use super::*;
236
+ use crate::transport::test_support::OfflineTransport;
237
+ use crate::transport::{PaneId, PaneInfo, SessionName};
238
+ use serde_json::json;
239
+ use std::path::PathBuf;
240
+
241
+ #[test]
242
+ fn pending_receiver_is_not_a_live_channel() {
243
+ let workspace = PathBuf::from("/tmp/claim-rework-a3-pending");
244
+ let receiver = json!({
245
+ "mode": "direct_tmux",
246
+ "status": "pending",
247
+ "pane_id": "%1",
248
+ "tmux_socket": "/tmp/ta-offline.sock"
249
+ });
250
+ let transport = OfflineTransport::default().with_tmux_endpoint("/tmp/ta-offline.sock");
251
+ match resolve_live_leader_channel(&workspace, &receiver, &transport) {
252
+ LeaderChannelResolution::Unbound(LeaderChannelUnbound::ReceiverNotAttached) => {}
253
+ other => panic!("pending seed must not be live; got {other:?}"),
254
+ }
255
+ }
256
+
257
+ #[test]
258
+ fn dead_pane_with_matching_endpoint_is_pane_not_live() {
259
+ let workspace = PathBuf::from("/tmp/claim-rework-a3-dead");
260
+ let endpoint = "/tmp/ta-claim-rework-a3.sock";
261
+ let receiver = json!({
262
+ "mode": "direct_tmux",
263
+ "status": "attached",
264
+ "pane_id": "%missing",
265
+ "tmux_socket": endpoint
266
+ });
267
+ let transport = OfflineTransport::default()
268
+ .with_tmux_endpoint(endpoint)
269
+ .with_targets(vec![PaneInfo {
270
+ pane_id: PaneId::new("%other"),
271
+ session: SessionName::new("team"),
272
+ window_index: None,
273
+ window_name: None,
274
+ pane_index: None,
275
+ tty: None,
276
+ current_command: None,
277
+ current_path: None,
278
+ active: true,
279
+ pane_pid: None,
280
+ leader_env: Default::default(),
281
+ }]);
282
+ match resolve_live_leader_channel(&workspace, &receiver, &transport) {
283
+ LeaderChannelResolution::Unbound(LeaderChannelUnbound::PaneNotLive) => {}
284
+ other => panic!("matching endpoint + missing pane must be PaneNotLive; got {other:?}"),
285
+ }
286
+ }
287
+ }