@team-agent/installer 0.5.118 → 0.5.120

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 (72) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/README.md +46 -1
  4. package/README.zh.md +46 -1
  5. package/crates/team-agent/src/cli/adapters.rs +11 -0
  6. package/crates/team-agent/src/cli/arguments.rs +401 -0
  7. package/crates/team-agent/src/cli/emit.rs +241 -142
  8. package/crates/team-agent/src/cli/intent/runtime.rs +1139 -0
  9. package/crates/team-agent/src/cli/intent/runtime_tests.rs +584 -0
  10. package/crates/team-agent/src/cli/intent/tests.rs +654 -0
  11. package/crates/team-agent/src/cli/intent.rs +642 -0
  12. package/crates/team-agent/src/cli/mod.rs +49 -5
  13. package/crates/team-agent/src/cli/named_address.rs +10 -1
  14. package/crates/team-agent/src/cli/spec.rs +22 -10
  15. package/crates/team-agent/src/cli/status_port/brief.rs +117 -16
  16. package/crates/team-agent/src/cli/tests/base.rs +6 -1
  17. package/crates/team-agent/src/cli/tests/parser_safety.rs +383 -0
  18. package/crates/team-agent/src/cli/tests/run_delegation.rs +63 -19
  19. package/crates/team-agent/src/cli/tests/verb_install_skill.rs +3 -2
  20. package/crates/team-agent/src/cli/tests/verb_profile.rs +3 -1
  21. package/crates/team-agent/src/contract_runtime/backend.rs +1 -1
  22. package/crates/team-agent/src/db/message_store.rs +10 -3
  23. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +166 -77
  24. package/crates/team-agent/src/lifecycle/launch.rs +28 -0
  25. package/crates/team-agent/src/lifecycle/restart.rs +17 -0
  26. package/crates/team-agent/src/lifecycle/tests/acceptance_b_batch_red.rs +1 -3
  27. package/crates/team-agent/src/lifecycle/tests/behavioral_diff_264_red.rs +5 -15
  28. package/crates/team-agent/src/lifecycle/tests/claude_compatible_config_red.rs +2 -3
  29. package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +2 -3
  30. package/crates/team-agent/src/lifecycle/tests/cli_argv_routing_red.rs +10 -2
  31. package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +8 -1
  32. package/crates/team-agent/src/lifecycle/tests/clone_fork_copilot_perms_red.rs +3 -2
  33. package/crates/team-agent/src/lifecycle/tests/communication_mode_runtime_contract_red.rs +6 -9
  34. package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +7 -5
  35. package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +4 -2
  36. package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +5 -2
  37. package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +4 -3
  38. package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +2 -4
  39. package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +3 -4
  40. package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +5 -4
  41. package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +2 -4
  42. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +78 -36
  43. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +8 -4
  44. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +6 -12
  45. package/crates/team-agent/src/lifecycle/tests/pi_fork_session_inheritance_red.rs +4 -0
  46. package/crates/team-agent/src/lifecycle/tests/startup_latency_contract.rs +1 -3
  47. package/crates/team-agent/src/lifecycle/tests/status_credential_redaction_contract.rs +7 -0
  48. package/crates/team-agent/src/lifecycle/tests/team_in_team_identity_scope_red.rs +1 -3
  49. package/crates/team-agent/src/lifecycle/tests/verify_rs031_window_consistency_red.rs +2 -1
  50. package/crates/team-agent/src/lifecycle/types.rs +10 -0
  51. package/crates/team-agent/src/messaging/delivery.rs +132 -39
  52. package/crates/team-agent/src/messaging/delivery_consumption_tests.rs +223 -0
  53. package/crates/team-agent/src/messaging/enter_rescue.rs +182 -154
  54. package/crates/team-agent/src/messaging/enter_rescue_tests.rs +344 -277
  55. package/crates/team-agent/src/messaging/mod.rs +1 -1
  56. package/crates/team-agent/src/tmux_backend/enter_rescue.rs +325 -0
  57. package/crates/team-agent/src/tmux_backend/enter_rescue_tests.rs +432 -0
  58. package/crates/team-agent/src/tmux_backend/native_input.rs +19 -0
  59. package/crates/team-agent/src/tmux_backend/tests.rs +37 -0
  60. package/crates/team-agent/src/tmux_backend.rs +35 -1
  61. package/crates/team-agent/src/transport/enter_rescue.rs +166 -0
  62. package/crates/team-agent/src/transport/native_input.rs +5 -1
  63. package/crates/team-agent/src/transport/test_support.rs +25 -0
  64. package/crates/team-agent/src/transport.rs +34 -0
  65. package/package.json +4 -4
  66. package/skills/team-agent/SKILL.md +71 -54
  67. package/skills/team-agent/command-coverage.json +283 -13
  68. package/skills/team-agent/docs/reference/team-agent-operator.md +220 -0
  69. package/skills/team-agent/references/bug-as-artifact-flow.md +1 -1
  70. package/skills/team-agent/references/recovery-runbook.md +61 -212
  71. package/skills/team-agent/references/team-agent-operator.md +181 -48
  72. package/skills/team-agent/references/team-in-team.md +47 -80
@@ -0,0 +1,432 @@
1
+ #![allow(clippy::unwrap_used, clippy::panic)]
2
+ use super::*;
3
+ use crate::model::enums::Provider;
4
+ use std::sync::atomic::{AtomicUsize, Ordering};
5
+ use std::sync::{Arc, Mutex};
6
+
7
+ #[derive(Clone, Copy)]
8
+ enum Mode {
9
+ Normal,
10
+ Busy,
11
+ Unknown,
12
+ Capsule,
13
+ History,
14
+ Changed,
15
+ UnknownAfterEnter,
16
+ UnknownWithReplacement,
17
+ }
18
+ struct State {
19
+ mode: Mode,
20
+ nonce: String,
21
+ endpoint: String,
22
+ pid: u32,
23
+ captures: usize,
24
+ entered: usize,
25
+ calls: Vec<Vec<String>>,
26
+ }
27
+ struct Runner(Arc<Mutex<State>>);
28
+ fn pi(composer: &str, history: &str, busy: bool) -> String {
29
+ format!("{history}\n{}\n{composer}\n────────────────────────\ngpt-6-luna · max · 0%/272k · 0% · $0.00\n",if busy{"── ⠋ Working ──────────────────"}else{"────────────────────"})
30
+ }
31
+ impl CommandRunner for Runner {
32
+ fn run(&self, argv: &[String]) -> Result<CommandOutput, std::io::Error> {
33
+ let mut s = self.0.lock().unwrap();
34
+ s.calls.push(argv.to_vec());
35
+ let stdout = if argv.iter().any(|a| a == "display-message") {
36
+ let format = argv.last().unwrap();
37
+ if format.contains("socket_path") {
38
+ let nonce = if (matches!(s.mode, Mode::UnknownAfterEnter) && s.entered > 0)
39
+ || (matches!(s.mode, Mode::UnknownWithReplacement) && s.captures > 0)
40
+ {
41
+ "replaced"
42
+ } else {
43
+ &s.nonce
44
+ };
45
+ format!("{}|%1|{}|session|worker|{nonce}|0|120\n", s.endpoint, s.pid)
46
+ } else if format == "#{pane_height}" {
47
+ "120\n".into()
48
+ } else if format == "#{pane_pid}" {
49
+ format!("{}\n", s.pid)
50
+ } else {
51
+ "0\n".into()
52
+ }
53
+ } else if argv.iter().any(|a| a == "set-option") {
54
+ assert!(s.nonce.is_empty());
55
+ assert!(argv.iter().any(|a| a == "-o"));
56
+ s.nonce = argv.last().unwrap().clone();
57
+ String::new()
58
+ } else if argv.iter().any(|a| a == "show-options") {
59
+ format!("{}\n", s.nonce)
60
+ } else if argv.iter().any(|a| a == "capture-pane") {
61
+ s.captures += 1;
62
+ let marker = "[team-agent-token:msg_one]";
63
+ match s.mode {
64
+ Mode::Unknown | Mode::UnknownWithReplacement => "unknown native screen".into(),
65
+ Mode::Capsule => pi("[paste #1 +30 lines]", "", false),
66
+ Mode::History => pi("", marker, false),
67
+ Mode::Changed if s.captures >= 3 => pi("different draft", "", false),
68
+ Mode::Busy => pi(marker, "", true),
69
+ _ if s.entered > 0 => pi("", marker, false),
70
+ _ => pi(marker, "", false),
71
+ }
72
+ } else if argv.iter().any(|a| a == "send-keys") {
73
+ assert_eq!(argv.last().unwrap(), "Enter");
74
+ s.entered += 1;
75
+ String::new()
76
+ } else {
77
+ panic!("unexpected transport operation {argv:?}")
78
+ };
79
+ Ok(CommandOutput {
80
+ success: true,
81
+ code: Some(0),
82
+ stdout,
83
+ stderr: String::new(),
84
+ })
85
+ }
86
+ }
87
+ struct Fixture {
88
+ path: std::path::PathBuf,
89
+ backend: TmuxBackend,
90
+ state: Arc<Mutex<State>>,
91
+ }
92
+ impl Fixture {
93
+ fn new(mode: Mode) -> Self {
94
+ static NEXT: AtomicUsize = AtomicUsize::new(0);
95
+ let path = std::env::temp_dir().join(format!(
96
+ "ta-independent-rescue-backend-{}-{}",
97
+ std::process::id(),
98
+ NEXT.fetch_add(1, Ordering::SeqCst)
99
+ ));
100
+ std::fs::create_dir(&path).unwrap();
101
+ let state = Arc::new(Mutex::new(State {
102
+ mode,
103
+ nonce: "original-nonce".into(),
104
+ endpoint: "/tmp/owned.sock".into(),
105
+ pid: std::process::id(),
106
+ captures: 0,
107
+ entered: 0,
108
+ calls: vec![],
109
+ }));
110
+ let backend =
111
+ TmuxBackend::with_runner_for_workspace(Box::new(Runner(state.clone())), &path);
112
+ Self {
113
+ path,
114
+ backend,
115
+ state,
116
+ }
117
+ }
118
+ fn context(&self) -> EnterRescueContext {
119
+ EnterRescueContext {
120
+ message_id: "msg_one".into(),
121
+ owner_team_id: "team".into(),
122
+ recipient: "worker".into(),
123
+ payload_sha256: digest("rendered payload"),
124
+ target: Some(EnterRescueTarget::selected(
125
+ Provider::Pi,
126
+ "%1".into(),
127
+ std::process::id(),
128
+ "session".into(),
129
+ "worker".into(),
130
+ )),
131
+ proof: None,
132
+ }
133
+ }
134
+ fn snapshot(&self) -> EnterRescueContext {
135
+ self.backend
136
+ .snapshot_rescue_context(
137
+ &Target::Pane(PaneId::new("%1")),
138
+ &self.context(),
139
+ "rendered payload",
140
+ )
141
+ .unwrap()
142
+ }
143
+ fn entered(&self) -> usize {
144
+ self.state.lock().unwrap().entered
145
+ }
146
+ }
147
+ impl Drop for Fixture {
148
+ fn drop(&mut self) {
149
+ let _ = std::fs::remove_dir_all(&self.path);
150
+ }
151
+ }
152
+
153
+ #[test]
154
+ fn original_boundary_snapshot_records_only_exact_visible_current_marker() {
155
+ let f = Fixture::new(Mode::Normal);
156
+ let c = f.snapshot();
157
+ assert!(c.target.as_ref().unwrap().bound());
158
+ let proof = c.proof.unwrap();
159
+ assert!(proof.visible_marker && proof.transcript_marker_absent);
160
+ assert!(proof.owned_capsule.is_none());
161
+ assert_eq!(f.entered(), 0);
162
+ for mode in [Mode::Unknown, Mode::Capsule, Mode::History] {
163
+ let f = Fixture::new(mode);
164
+ let c = f.snapshot();
165
+ assert!(c.proof.is_none());
166
+ assert_eq!(f.entered(), 0);
167
+ }
168
+ }
169
+
170
+ #[test]
171
+ fn snapshot_rejects_wrong_payload_or_selected_carrier_and_creates_nonce_only_when_owned() {
172
+ let f = Fixture::new(Mode::Normal);
173
+ let c = f.context();
174
+ let t = Target::Pane(PaneId::new("%1"));
175
+ assert!(f
176
+ .backend
177
+ .snapshot_rescue_context(&t, &c, "edited")
178
+ .unwrap()
179
+ .proof
180
+ .is_none());
181
+ assert!(f.state.lock().unwrap().calls.is_empty());
182
+ let mut c = f.context();
183
+ c.target.as_mut().unwrap().pane_pid += 1;
184
+ assert!(f
185
+ .backend
186
+ .snapshot_rescue_context(&t, &c, "rendered payload")
187
+ .unwrap()
188
+ .proof
189
+ .is_none());
190
+ let f = Fixture::new(Mode::Capsule);
191
+ f.state.lock().unwrap().nonce.clear();
192
+ assert!(f.snapshot().proof.is_none());
193
+ assert!(f.state.lock().unwrap().nonce.is_empty());
194
+ let f = Fixture::new(Mode::Normal);
195
+ f.state.lock().unwrap().nonce.clear();
196
+ assert!(f.snapshot().target.unwrap().bound());
197
+ assert!(!f.state.lock().unwrap().nonce.is_empty());
198
+ assert_eq!(f.entered(), 0);
199
+ }
200
+
201
+ #[test]
202
+ fn watchdog_rechecks_carrier_and_never_adopts_replacement_or_missing_receipt() {
203
+ for changed in ["endpoint", "nonce", "birth", "pid"] {
204
+ let f = Fixture::new(Mode::Normal);
205
+ let mut c = f.snapshot();
206
+ match changed {
207
+ "endpoint" => f.state.lock().unwrap().endpoint = "/tmp/other.sock".into(),
208
+ "nonce" => f.state.lock().unwrap().nonce = "other".into(),
209
+ "birth" => c.target.as_mut().unwrap().carrier_birth = "reused".into(),
210
+ _ => c.target.as_mut().unwrap().pane_pid += 1,
211
+ };
212
+ let _ = f.backend.rescue_guarded_enter(&c, &|| true, None);
213
+ assert_eq!(f.entered(), 0, "{changed}");
214
+ }
215
+ let f = Fixture::new(Mode::Normal);
216
+ assert!(matches!(
217
+ f.backend
218
+ .rescue_guarded_enter(&f.context(), &|| true, None)
219
+ .unwrap(),
220
+ EnterRescueOutcome::NoEffect { .. }
221
+ ));
222
+ assert_eq!(f.entered(), 0);
223
+ }
224
+
225
+ #[test]
226
+ fn busy_unstable_unknown_or_final_authorization_failure_sends_no_key() {
227
+ for mode in [Mode::Busy, Mode::Changed] {
228
+ let f = Fixture::new(mode);
229
+ let c = f.snapshot();
230
+ assert!(matches!(
231
+ f.backend.rescue_guarded_enter(&c, &|| true, None).unwrap(),
232
+ EnterRescueOutcome::NoEffect { .. }
233
+ ));
234
+ assert_eq!(f.entered(), 0);
235
+ }
236
+ let f = Fixture::new(Mode::Normal);
237
+ let c = f.snapshot();
238
+ f.state.lock().unwrap().mode = Mode::Unknown;
239
+ assert_eq!(
240
+ f.backend.rescue_guarded_enter(&c, &|| true, None).unwrap(),
241
+ no_effect("surface_unknown", true)
242
+ );
243
+ assert_eq!(f.entered(), 0);
244
+ let f = Fixture::new(Mode::Normal);
245
+ let c = f.snapshot();
246
+ assert!(matches!(
247
+ f.backend.rescue_guarded_enter(&c, &|| false, None).unwrap(),
248
+ EnterRescueOutcome::NoEffect { .. }
249
+ ));
250
+ assert_eq!(f.entered(), 0);
251
+ }
252
+
253
+ #[test]
254
+ fn unknown_observation_still_checks_original_carrier_before_zero_effect_refusal() {
255
+ let f = Fixture::new(Mode::Normal);
256
+ let c = f.snapshot();
257
+ {
258
+ let mut state = f.state.lock().unwrap();
259
+ state.mode = Mode::UnknownWithReplacement;
260
+ state.captures = 0;
261
+ }
262
+ assert!(matches!(
263
+ f.backend.rescue_guarded_enter(&c, &|| true, None),
264
+ Err(TransportError::NativeInputNotReady { reason }) if reason == "rescue_binding_changed"
265
+ ));
266
+ assert_eq!(f.entered(), 0);
267
+ }
268
+
269
+ #[test]
270
+ fn required_input_lock_cannot_be_omitted_or_bypassed() {
271
+ let f = Fixture::new(Mode::Normal);
272
+ let c = f.snapshot();
273
+ let _lock = acquire_required(PaneInputLockRequest {
274
+ workspace: Some(&f.path),
275
+ target_key: "%1",
276
+ operation: "fixture-held",
277
+ })
278
+ .unwrap();
279
+ assert_eq!(
280
+ f.backend.rescue_guarded_enter(&c, &|| true, None).unwrap(),
281
+ no_effect("input_lock", true)
282
+ );
283
+ assert_eq!(f.entered(), 0);
284
+ let f = Fixture::new(Mode::Normal);
285
+ let c = f.snapshot();
286
+ let backend = TmuxBackend::with_runner(Box::new(Runner(f.state.clone())));
287
+ assert_eq!(
288
+ backend.rescue_guarded_enter(&c, &|| true, None).unwrap(),
289
+ no_effect("input_lock", true)
290
+ );
291
+ assert_eq!(f.entered(), 0);
292
+ }
293
+
294
+ struct Observer(AtomicUsize);
295
+ impl SubmitObserver for Observer {
296
+ fn after_physical_submit(&self) {
297
+ self.0.fetch_add(1, Ordering::SeqCst);
298
+ }
299
+ }
300
+ #[test]
301
+ fn one_logical_enter_notifies_before_verification_and_never_pastes() {
302
+ let f = Fixture::new(Mode::Normal);
303
+ let c = f.snapshot();
304
+ let observer = Observer(AtomicUsize::new(0));
305
+ assert_eq!(
306
+ f.backend
307
+ .rescue_guarded_enter(&c, &|| true, Some(&observer))
308
+ .unwrap(),
309
+ EnterRescueOutcome::Entered { accepted: true }
310
+ );
311
+ assert_eq!(f.entered(), 1);
312
+ assert_eq!(observer.0.load(Ordering::SeqCst), 1);
313
+ let s = f.state.lock().unwrap();
314
+ assert!(!s
315
+ .calls
316
+ .iter()
317
+ .flatten()
318
+ .any(|a| matches!(a.as_str(), "paste-buffer" | "load-buffer" | "set-buffer")));
319
+ }
320
+
321
+ #[test]
322
+ fn already_consumed_transition_never_sends_another_enter_or_fakes_observer() {
323
+ let f = Fixture::new(Mode::Normal);
324
+ let c = f.snapshot();
325
+ f.state.lock().unwrap().mode = Mode::History;
326
+ let observer = Observer(AtomicUsize::new(0));
327
+ assert_eq!(
328
+ f.backend
329
+ .rescue_guarded_enter(&c, &|| true, Some(&observer))
330
+ .unwrap(),
331
+ EnterRescueOutcome::Consumed
332
+ );
333
+ assert_eq!(f.entered(), 0);
334
+ assert_eq!(observer.0.load(Ordering::SeqCst), 0);
335
+ }
336
+
337
+ #[test]
338
+ fn foreign_token_or_bare_capsule_is_not_current_message_key_authority() {
339
+ let f = Fixture::new(Mode::Normal);
340
+ let mut c = f.snapshot();
341
+ let mut frame = f
342
+ .backend
343
+ .rescue_frame(c.target.as_ref().unwrap())
344
+ .unwrap()
345
+ .unwrap();
346
+ frame.composer.push_str("\n[team-agent-token:msg_foreign]");
347
+ assert!(!pending(&c, &frame));
348
+ frame.composer = "[paste #1 +30 lines]".into();
349
+ frame.capsule = Some(frame.composer.clone());
350
+ assert!(!pending(&c, &frame));
351
+ let scope_sha256 = c.scope_sha256().unwrap();
352
+ let proof = c.proof.as_mut().unwrap();
353
+ proof.visible_marker = false;
354
+ proof.owned_capsule = Some(crate::transport::enter_rescue::OwnedCapsule {
355
+ scope_sha256,
356
+ label: frame.composer.clone(),
357
+ baseline_sha256: digest("original baseline"),
358
+ baseline_capsules: vec![],
359
+ observed_after_owned_paste: true,
360
+ });
361
+ assert!(pending(&c, &frame));
362
+ for field in [
363
+ "message",
364
+ "payload",
365
+ "owner",
366
+ "recipient",
367
+ "nonce",
368
+ "birth",
369
+ "endpoint",
370
+ ] {
371
+ let mut reassigned = c.clone();
372
+ match field {
373
+ "message" => reassigned.message_id = "msg_other".into(),
374
+ "payload" => reassigned.payload_sha256 = digest("other payload"),
375
+ "owner" => reassigned.owner_team_id = "other".into(),
376
+ "recipient" => reassigned.recipient = "other".into(),
377
+ "nonce" => reassigned.target.as_mut().unwrap().binding_nonce = "other".into(),
378
+ "birth" => reassigned.target.as_mut().unwrap().carrier_birth = "other".into(),
379
+ _ => reassigned.target.as_mut().unwrap().endpoint = "/tmp/other.sock".into(),
380
+ }
381
+ assert!(!pending(&reassigned, &frame), "{field}");
382
+ }
383
+ c.proof
384
+ .as_mut()
385
+ .unwrap()
386
+ .owned_capsule
387
+ .as_mut()
388
+ .unwrap()
389
+ .baseline_capsules
390
+ .push(frame.composer.clone());
391
+ assert!(!pending(&c, &frame));
392
+ }
393
+
394
+ #[test]
395
+ fn unknown_after_enter_is_terminal_uncertainty_not_no_effect() {
396
+ let f = Fixture::new(Mode::UnknownAfterEnter);
397
+ let c = f.snapshot();
398
+ let observer = Observer(AtomicUsize::new(0));
399
+ assert_eq!(
400
+ f.backend
401
+ .rescue_guarded_enter(&c, &|| true, Some(&observer))
402
+ .unwrap(),
403
+ EnterRescueOutcome::Entered { accepted: false }
404
+ );
405
+ assert_eq!(f.entered(), 1);
406
+ assert_eq!(observer.0.load(Ordering::SeqCst), 1);
407
+ }
408
+
409
+ #[test]
410
+ fn transcript_transfer_requires_empty_not_whitespace_composer_and_unique_marker() {
411
+ let f = Fixture::new(Mode::Normal);
412
+ let c = f.snapshot();
413
+ let proof = c.proof.as_ref().unwrap();
414
+ let mut frame = InputFrame {
415
+ composer: "".into(),
416
+ transcript: c.marker(),
417
+ identity: proof.ui_identity.clone(),
418
+ footer: "changed while working".into(),
419
+ busy: true,
420
+ capsule: None,
421
+ };
422
+ assert!(accepted(&c, &frame));
423
+ frame.composer = " ".into();
424
+ assert!(!accepted(&c, &frame));
425
+ frame.composer.clear();
426
+ frame.transcript = format!("{}{}", c.marker(), c.marker());
427
+ assert!(!accepted(&c, &frame));
428
+ frame.transcript = c.marker();
429
+ let mut c = c;
430
+ c.proof.as_mut().unwrap().transcript_marker_absent = false;
431
+ assert!(!accepted(&c, &frame));
432
+ }
@@ -38,6 +38,25 @@ pub(crate) fn digest(text: &str) -> String {
38
38
  }
39
39
 
40
40
  impl TmuxBackend {
41
+ pub(super) fn observe_current_native_input(
42
+ &self,
43
+ target: &Target,
44
+ provider: Provider,
45
+ ) -> Result<Option<InputFrame>, TransportError> {
46
+ if !crate::transport::native_input::supports_input_observation(provider) {
47
+ return Ok(None);
48
+ }
49
+ // Pure observation: no nonce initialization, paste, or key.
50
+ let binding = self.query_native_binding(target, true)?;
51
+ let capture = self.capture(target, CaptureRange::Tail(200))?;
52
+ let frame = inspect_input(provider, &capture.text, binding.pane_height)
53
+ .ok_or_else(|| refused("native_surface_unknown"))?;
54
+ if self.query_native_binding(target, true)? != binding {
55
+ return Err(refused("binding_changed"));
56
+ }
57
+ Ok(Some(frame))
58
+ }
59
+
41
60
  fn native_binding(&self, target: &Target) -> Result<NativeInputBinding, TransportError> {
42
61
  self.query_native_binding(target, false)
43
62
  }
@@ -2217,6 +2217,43 @@ fn kill_session_and_kill_window_argv() {
2217
2217
  );
2218
2218
  }
2219
2219
 
2220
+ #[test]
2221
+ fn issue336_session_listing_accepts_tab_and_literal_escape_without_rewriting_names() {
2222
+ for separator in ["\t", "\\t"] {
2223
+ let (backend, calls) = backend_with(
2224
+ MockResp::Out(ok("")),
2225
+ vec![
2226
+ MockResp::Out(ok(&format!("%7{separator}worker\\tname\n"))),
2227
+ MockResp::Out(ok("%8\n")),
2228
+ ],
2229
+ );
2230
+ backend.preserve_session_before_stop(
2231
+ &SessionName::new("sess"),
2232
+ &[Target::SessionWindow {
2233
+ session: SessionName::new("sess"),
2234
+ window: WindowName::new("worker\\tname"),
2235
+ }],
2236
+ ).unwrap();
2237
+ assert_eq!(calls.lock().unwrap().len(), 4, "separator={separator:?}");
2238
+ }
2239
+ }
2240
+
2241
+ #[test]
2242
+ fn issue336_escaped_listing_keeps_survivors_and_malformed_rows_fail_closed() {
2243
+ let (backend, calls) = backend_with(MockResp::Out(ok("%7\\tworker\n%8\\tleader\n")), vec![]);
2244
+ backend.preserve_session_before_stop(
2245
+ &SessionName::new("sess"), &[Target::Pane(PaneId::new("%7"))],
2246
+ ).unwrap();
2247
+ assert_eq!(calls.lock().unwrap().len(), 1);
2248
+ for row in ["%7 worker", "%7", ""] {
2249
+ let (backend, calls) = backend_with(MockResp::Out(ok(row)), vec![]);
2250
+ assert!(backend.preserve_session_before_stop(
2251
+ &SessionName::new("sess"), &[Target::Pane(PaneId::new("%7"))],
2252
+ ).is_err());
2253
+ assert_eq!(calls.lock().unwrap().len(), 1);
2254
+ }
2255
+ }
2256
+
2220
2257
  #[test]
2221
2258
  fn stop_session_preservation_creates_processless_anchor_by_exact_pane_id() {
2222
2259
  let (be, rec) = backend_with(
@@ -73,6 +73,7 @@ use crate::transport::{
73
73
  SubmitVerification, Target, Transport, TransportError, TurnVerification, WindowName,
74
74
  };
75
75
 
76
+ mod enter_rescue;
76
77
  mod native_input;
77
78
 
78
79
  pub const PANE_BINDING_NONCE_METADATA_KEY: &str = "TEAM_AGENT_PANE_BINDING_NONCE";
@@ -3848,6 +3849,32 @@ impl Transport for TmuxBackend {
3848
3849
  })
3849
3850
  }
3850
3851
 
3852
+ fn observe_native_input(
3853
+ &self,
3854
+ target: &Target,
3855
+ provider: crate::model::enums::Provider,
3856
+ ) -> Result<Option<crate::transport::native_input::InputFrame>, TransportError> {
3857
+ self.observe_current_native_input(target, provider)
3858
+ }
3859
+
3860
+ fn snapshot_enter_rescue(
3861
+ &self,
3862
+ target: &Target,
3863
+ context: &crate::transport::enter_rescue::EnterRescueContext,
3864
+ rendered_payload: &str,
3865
+ ) -> Result<crate::transport::enter_rescue::EnterRescueContext, TransportError> {
3866
+ self.snapshot_rescue_context(target, context, rendered_payload)
3867
+ }
3868
+
3869
+ fn rescue_enter_guarded(
3870
+ &self,
3871
+ context: &crate::transport::enter_rescue::EnterRescueContext,
3872
+ authorize: &dyn Fn() -> bool,
3873
+ observer: Option<&dyn SubmitObserver>,
3874
+ ) -> Result<crate::transport::enter_rescue::EnterRescueOutcome, TransportError> {
3875
+ self.rescue_guarded_enter(context, authorize, observer)
3876
+ }
3877
+
3851
3878
  fn rescue_enter(&self, evidence: &crate::transport::native_input::NativeInputEvidence, authorize: &dyn Fn() -> bool, observer: Option<&dyn SubmitObserver>)
3852
3879
  -> Result<crate::transport::native_input::EnterRescueOutcome, TransportError>
3853
3880
  {
@@ -4174,13 +4201,20 @@ impl Transport for TmuxBackend {
4174
4201
  return Err(subprocess_error(list_argv, output));
4175
4202
  }
4176
4203
  for line in output.stdout.lines() {
4177
- let Some((pane, window)) = line.split_once('\t') else {
4204
+ // Some tmux/runner combinations return the literal escape. Split
4205
+ // only the first column boundary; window names are not unescaped.
4206
+ let separator = line.find('\t').map(|at| (at, 1)).into_iter()
4207
+ .chain(line.find("\\t").map(|at| (at, 2)))
4208
+ .min_by_key(|(at, _)| *at);
4209
+ let Some((at, width)) = separator else {
4178
4210
  return Err(TransportError::Subprocess {
4179
4211
  argv: list_argv,
4180
4212
  code: output.code,
4181
4213
  stderr: "tmux returned an invalid session pane listing".to_string(),
4182
4214
  });
4183
4215
  };
4216
+ let (pane, rest) = line.split_at(at);
4217
+ let window = &rest[width..];
4184
4218
  if !targets.iter().any(|target| match target {
4185
4219
  Target::Pane(id) => id.as_str() == pane,
4186
4220
  Target::SessionWindow {