@team-agent/installer 0.5.115 → 0.5.117

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 (36) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/mod.rs +143 -12
  4. package/crates/team-agent/src/cli/shutdown_socket.rs +288 -0
  5. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +11 -4
  6. package/crates/team-agent/src/contract_runtime/backend.rs +88 -8
  7. package/crates/team-agent/src/contract_runtime/commands.rs +88 -7
  8. package/crates/team-agent/src/contract_runtime/discovery.rs +42 -10
  9. package/crates/team-agent/src/contract_runtime/mcp.rs +3 -0
  10. package/crates/team-agent/src/contract_runtime/pump.rs +2 -0
  11. package/crates/team-agent/src/contract_runtime/registry.rs +86 -9
  12. package/crates/team-agent/src/contract_runtime/workspace.rs +79 -11
  13. package/crates/team-agent/src/leader/lease.rs +1 -18
  14. package/crates/team-agent/src/lifecycle/pane_input_lock.rs +86 -8
  15. package/crates/team-agent/src/lifecycle/restart/agent.rs +4 -0
  16. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +101 -72
  17. package/crates/team-agent/src/lifecycle/restart/selection.rs +30 -8
  18. package/crates/team-agent/src/messaging/delivery.rs +132 -27
  19. package/crates/team-agent/src/messaging/enter_rescue.rs +334 -0
  20. package/crates/team-agent/src/messaging/enter_rescue_tests.rs +566 -0
  21. package/crates/team-agent/src/messaging/helpers.rs +1 -0
  22. package/crates/team-agent/src/messaging/mod.rs +1 -0
  23. package/crates/team-agent/src/messaging/scheduler.rs +8 -0
  24. package/crates/team-agent/src/messaging/tests/basic.rs +2 -0
  25. package/crates/team-agent/src/messaging/types.rs +1 -0
  26. package/crates/team-agent/src/provider/session/capture.rs +24 -1
  27. package/crates/team-agent/src/state/persist.rs +72 -2
  28. package/crates/team-agent/src/state/projection.rs +29 -0
  29. package/crates/team-agent/src/state/repository/tests.rs +70 -0
  30. package/crates/team-agent/src/state/repository.rs +52 -1
  31. package/crates/team-agent/src/tmux_backend/native_input.rs +494 -0
  32. package/crates/team-agent/src/tmux_backend/native_input_tests.rs +781 -0
  33. package/crates/team-agent/src/tmux_backend.rs +49 -6
  34. package/crates/team-agent/src/transport/native_input.rs +594 -0
  35. package/crates/team-agent/src/transport.rs +19 -0
  36. package/package.json +4 -4
@@ -0,0 +1,494 @@
1
+ //! Guarded legacy native-input effects. Shared contract parsers remain observation-only.
2
+
3
+ use std::time::{Duration, Instant};
4
+
5
+ use sha2::{Digest, Sha256};
6
+
7
+ use super::*;
8
+ use crate::lifecycle::pane_input_lock::{acquire_required, PaneInputLockRequest};
9
+ use crate::model::enums::Provider;
10
+ use crate::transport::native_input::{
11
+ inspect_input, pi_paste_capsule, record_evidence, EnterRescueOutcome, InputFrame,
12
+ NativeInputBinding, NativeInputEvidence, NativeInputPhase, NativeInputRequest,
13
+ };
14
+
15
+ #[cfg(test)]
16
+ #[path = "native_input_tests.rs"]
17
+ mod tests;
18
+
19
+ const STABLE: Duration = Duration::from_millis(200);
20
+ const OBSERVE: Duration = Duration::from_secs(10);
21
+ const POLL: Duration = Duration::from_millis(50);
22
+
23
+ fn refused(reason: &str) -> TransportError {
24
+ TransportError::NativeInputNotReady {
25
+ reason: reason.to_string(),
26
+ }
27
+ }
28
+
29
+ fn surface_unknown(error: &TransportError) -> bool {
30
+ matches!(
31
+ error,
32
+ TransportError::NativeInputNotReady { reason } if reason == "native_surface_unknown"
33
+ )
34
+ }
35
+
36
+ pub(crate) fn digest(text: &str) -> String {
37
+ format!("{:x}", Sha256::digest(text.as_bytes()))
38
+ }
39
+
40
+ impl TmuxBackend {
41
+ fn native_binding(&self, target: &Target) -> Result<NativeInputBinding, TransportError> {
42
+ self.query_native_binding(target, false)
43
+ }
44
+
45
+ fn query_native_binding(
46
+ &self,
47
+ target: &Target,
48
+ allow_unset_nonce: bool,
49
+ ) -> Result<NativeInputBinding, TransportError> {
50
+ let pane = pane_from_target(target);
51
+ let argv = self.tmux_argv(&[
52
+ "tmux".into(), "display-message".into(), "-p".into(), "-t".into(), pane.as_str().into(),
53
+ "#{socket_path}|#{pane_id}|#{pane_pid}|#{session_name}|#{window_name}|#{@team_agent_pane_binding_nonce}|#{pane_in_mode}|#{pane_height}".into(),
54
+ ]);
55
+ let output = self.runner.run(&argv)?;
56
+ if !output.success || output.stdout.lines().count() != 1 {
57
+ return Err(refused("binding_query"));
58
+ }
59
+ let fields: Vec<_> = output.stdout.trim_end().split('|').collect();
60
+ if fields.len() != 8 {
61
+ return Err(refused("binding_invalid:field_count"));
62
+ }
63
+ for (invalid, field) in [
64
+ (!std::path::Path::new(fields[0]).is_absolute(), "endpoint"),
65
+ (fields[1] != pane.as_str(), "pane"),
66
+ (fields[3].is_empty(), "session_empty"),
67
+ (fields[4].is_empty(), "window_empty"),
68
+ (fields[5].is_empty() && !allow_unset_nonce, "nonce_empty"),
69
+ (fields[6] != "0", "pane_in_mode"),
70
+ ] {
71
+ if invalid {
72
+ // Field class only: no argv, environment, business text or raw
73
+ // native metadata is copied into the existing refusal event.
74
+ return Err(refused(&format!("binding_invalid:{field}")));
75
+ }
76
+ }
77
+ let pane_height = fields[7]
78
+ .parse::<u32>()
79
+ .map_err(|_| refused("binding_height"))?;
80
+ if pane_height < 10 {
81
+ return Err(refused("binding_height"));
82
+ }
83
+ let pid = fields[2]
84
+ .parse::<u32>()
85
+ .map_err(|_| refused("binding_pid"))?;
86
+ let birth = team_agent_contract::host::process::process_birth_identity(pid)
87
+ .map_err(|_| refused("carrier_birth"))?;
88
+ Ok(NativeInputBinding {
89
+ endpoint: fields[0].into(),
90
+ pane_id: fields[1].into(),
91
+ pane_pid: pid,
92
+ pane_height,
93
+ carrier_birth: birth,
94
+ session: fields[3].into(),
95
+ window: fields[4].into(),
96
+ binding_nonce: fields[5].into(),
97
+ })
98
+ }
99
+
100
+ // Called only under the required input lock. A newly launched legacy worker
101
+ // has no Leader lease nonce producer: initialize its exact current carrier,
102
+ // never overwrite an existing nonce or waive the subsequent strict reads.
103
+ fn prepare_native_binding(
104
+ &self,
105
+ target: &Target,
106
+ request: &NativeInputRequest,
107
+ ) -> Result<NativeInputBinding, TransportError> {
108
+ let initial = self.query_native_binding(target, true)?;
109
+ if initial.pane_id != request.pane_id
110
+ || initial.pane_pid != request.pane_pid
111
+ || initial.session != request.session
112
+ || initial.window != request.window
113
+ {
114
+ return Err(refused("selected_binding_changed"));
115
+ }
116
+ if !initial.binding_nonce.is_empty() {
117
+ return Ok(initial);
118
+ }
119
+ let pane = PaneId::new(&initial.pane_id);
120
+ let workspace = self
121
+ .event_workspace
122
+ .as_deref()
123
+ .ok_or_else(|| refused("workspace_missing"))?;
124
+ let proposed = next_pane_binding_nonce(workspace, &initial.endpoint, &pane);
125
+ let nonce = self.set_pane_binding_nonce_if_unset(&pane, &proposed)?;
126
+ let expected = NativeInputBinding {
127
+ binding_nonce: nonce,
128
+ ..initial
129
+ };
130
+ let observed = self.native_binding(target)?;
131
+ if observed != expected {
132
+ return Err(refused("binding_changed_after_nonce"));
133
+ }
134
+ Ok(observed)
135
+ }
136
+
137
+ fn native_frame(&self, evidence: &NativeInputEvidence) -> Result<InputFrame, TransportError> {
138
+ let target = Target::Pane(PaneId::new(&evidence.binding.pane_id));
139
+ if self.native_binding(&target)? != evidence.binding {
140
+ return Err(refused("binding_changed"));
141
+ }
142
+ let capture = self.capture(&target, CaptureRange::Tail(200))?;
143
+ let frame = inspect_input(
144
+ evidence.request.provider,
145
+ &capture.text,
146
+ evidence.binding.pane_height,
147
+ )
148
+ .ok_or_else(|| refused("native_surface_unknown"))?;
149
+ if self.native_binding(&target)? != evidence.binding {
150
+ return Err(refused("binding_changed"));
151
+ }
152
+ Ok(frame)
153
+ }
154
+
155
+ fn native_record(&self, evidence: &NativeInputEvidence) -> Result<(), TransportError> {
156
+ record_evidence(evidence);
157
+ let workspace = self
158
+ .event_workspace
159
+ .as_deref()
160
+ .ok_or_else(|| refused("workspace_missing"))?;
161
+ crate::event_log::EventLog::new(workspace)
162
+ .write(
163
+ "native_input.evidence",
164
+ serde_json::to_value(evidence).map_err(std::io::Error::other)?,
165
+ )
166
+ .map(|_| ())
167
+ .map_err(|error| TransportError::Io(std::io::Error::other(error)))
168
+ }
169
+
170
+ fn stable_frame(
171
+ &self,
172
+ evidence: &NativeInputEvidence,
173
+ empty: bool,
174
+ ) -> Result<InputFrame, TransportError> {
175
+ let start = Instant::now();
176
+ let stable_window = if empty {
177
+ STABLE
178
+ } else {
179
+ STABLE.max(current_paste_to_submit_floor())
180
+ };
181
+ let mut eligible: Option<(InputFrame, Instant)> = None;
182
+ while start.elapsed() < OBSERVE {
183
+ let frame = match self.native_frame(evidence) {
184
+ Ok(frame) => frame,
185
+ Err(error) if surface_unknown(&error) => {
186
+ // A partial redraw grants nothing and resets stability.
187
+ // Binding/process/capture failures still refuse immediately.
188
+ eligible = None;
189
+ std::thread::sleep(POLL);
190
+ continue;
191
+ }
192
+ Err(error) => return Err(error),
193
+ };
194
+ let valid = if empty {
195
+ frame.empty() && !frame.busy
196
+ } else {
197
+ frame.pending(evidence)
198
+ };
199
+ if valid {
200
+ if let Some((previous, since)) = &eligible {
201
+ if previous.composer == frame.composer
202
+ && previous.identity == frame.identity
203
+ && previous.capsule == frame.capsule
204
+ && since.elapsed() >= stable_window
205
+ {
206
+ return Ok(frame);
207
+ }
208
+ if previous.composer != frame.composer
209
+ || previous.identity != frame.identity
210
+ || previous.capsule != frame.capsule
211
+ {
212
+ eligible = Some((frame, Instant::now()));
213
+ }
214
+ } else {
215
+ eligible = Some((frame, Instant::now()));
216
+ }
217
+ } else {
218
+ eligible = None;
219
+ }
220
+ std::thread::sleep(POLL);
221
+ }
222
+ Err(refused(if empty {
223
+ "composer_not_ready"
224
+ } else {
225
+ "owned_paste_not_stable"
226
+ }))
227
+ }
228
+
229
+ fn observe_native_acceptance(
230
+ &self,
231
+ evidence: &NativeInputEvidence,
232
+ ) -> Result<bool, TransportError> {
233
+ let start = Instant::now();
234
+ let mut last_unknown = false;
235
+ while start.elapsed() < OBSERVE {
236
+ match self.native_frame(evidence) {
237
+ Ok(frame) => {
238
+ last_unknown = false;
239
+ if frame.accepted(evidence) {
240
+ return Ok(true);
241
+ }
242
+ }
243
+ Err(error) if surface_unknown(&error) => last_unknown = true,
244
+ Err(error) => return Err(error),
245
+ }
246
+ std::thread::sleep(POLL);
247
+ }
248
+ if last_unknown {
249
+ Err(refused("native_surface_unknown"))
250
+ } else {
251
+ Ok(false)
252
+ }
253
+ }
254
+
255
+ pub(super) fn inject_native_input(
256
+ &self,
257
+ target: &Target,
258
+ text: &str,
259
+ bracketed: bool,
260
+ request: NativeInputRequest,
261
+ observer: Option<&dyn SubmitObserver>,
262
+ ) -> Result<InjectReport, TransportError> {
263
+ let pane = pane_from_target(target);
264
+ let _lock = acquire_required(PaneInputLockRequest {
265
+ workspace: self.event_workspace.as_deref(),
266
+ target_key: pane.as_str(),
267
+ operation: "native_inject",
268
+ })
269
+ .ok_or_else(|| refused("input_lock"))?;
270
+ if !request.supported() {
271
+ return Err(refused("unsupported_profile"));
272
+ }
273
+ let binding = self.prepare_native_binding(target, &request)?;
274
+ let mut evidence = NativeInputEvidence {
275
+ request,
276
+ binding,
277
+ payload_sha256: digest(text),
278
+ ui_identity: String::new(),
279
+ footer_sha256: String::new(),
280
+ baseline_sha256: String::new(),
281
+ baseline_capsules: Vec::new(),
282
+ phase: NativeInputPhase::Ready,
283
+ capsule: None,
284
+ };
285
+ if evidence.binding.pane_id != evidence.request.pane_id
286
+ || evidence.binding.pane_pid != evidence.request.pane_pid
287
+ || evidence.binding.session != evidence.request.session
288
+ || evidence.binding.window != evidence.request.window
289
+ {
290
+ return Err(refused("selected_binding_changed"));
291
+ }
292
+ let baseline = self.stable_frame(&evidence, true)?;
293
+ if baseline.transcript.contains(&evidence.request.marker()) {
294
+ return Err(refused("marker_in_baseline"));
295
+ }
296
+ evidence.ui_identity = baseline.identity;
297
+ evidence.footer_sha256 = digest(&baseline.footer);
298
+ evidence.baseline_sha256 = digest(&baseline.transcript);
299
+ evidence.baseline_capsules =
300
+ capsule_labels(evidence.request.provider, &baseline.transcript);
301
+ self.native_record(&evidence)?;
302
+ let buffer = owned_buffer(&evidence);
303
+ for argv in tmux_inject_text_argv(&pane, &buffer, text, bracketed) {
304
+ let stage = inject_stage_for_argv(&argv);
305
+ if stage == InjectStage::PasteBuffer {
306
+ let frame = self.native_frame(&evidence)?;
307
+ if !frame.empty()
308
+ || frame.busy
309
+ || frame.identity != evidence.ui_identity
310
+ || digest(&frame.footer) != evidence.footer_sha256
311
+ {
312
+ return Err(refused("composer_changed_before_paste"));
313
+ }
314
+ evidence.phase = NativeInputPhase::MayHavePasted;
315
+ self.native_record(&evidence)?;
316
+ }
317
+ if stage == InjectStage::LoadBuffer {
318
+ self.run_inject_stage_with_stdin(&argv, stage, text)?;
319
+ } else {
320
+ self.run_inject_stage(&argv, stage)?;
321
+ }
322
+ if stage == InjectStage::PasteBuffer {
323
+ evidence.phase = NativeInputPhase::Pasted;
324
+ self.native_record(&evidence)?;
325
+ }
326
+ }
327
+ // A capsule becomes owned only after this empty-baseline, single-paste transition.
328
+ let start = Instant::now();
329
+ while start.elapsed() < OBSERVE {
330
+ let frame = match self.native_frame(&evidence) {
331
+ Ok(frame) => frame,
332
+ Err(error) if surface_unknown(&error) => {
333
+ std::thread::sleep(POLL);
334
+ continue;
335
+ }
336
+ Err(error) => return Err(error),
337
+ };
338
+ if frame.identity == evidence.ui_identity
339
+ && digest(&frame.footer) == evidence.footer_sha256
340
+ && !frame.busy
341
+ {
342
+ if let Some(capsule) = &frame.capsule {
343
+ if evidence.capsule.is_none() && !evidence.baseline_capsules.contains(capsule) {
344
+ evidence.capsule = Some(capsule.clone());
345
+ // Bind the empty-baseline, single-owned-paste transition
346
+ // before stability waiting can fail. This is still
347
+ // Pasted, not Seen or permission to submit a key.
348
+ self.native_record(&evidence)?;
349
+ }
350
+ }
351
+ if frame.pending(&evidence) {
352
+ break;
353
+ }
354
+ }
355
+ std::thread::sleep(POLL);
356
+ }
357
+ self.stable_frame(&evidence, false)?;
358
+ evidence.phase = NativeInputPhase::Seen;
359
+ self.native_record(&evidence)?;
360
+ self.native_submit(&mut evidence, observer)?;
361
+ let accepted = self.observe_native_acceptance(&evidence)?;
362
+ if accepted {
363
+ evidence.phase = NativeInputPhase::Accepted;
364
+ self.native_record(&evidence)?;
365
+ }
366
+ Ok(InjectReport {
367
+ stage_reached: InjectStage::Submit,
368
+ inject_verification: if evidence.capsule.is_some() {
369
+ InjectVerification::CaptureContainsNewPastedContentPrompt
370
+ } else {
371
+ InjectVerification::CaptureContainsToken
372
+ },
373
+ submit_verification: if accepted {
374
+ SubmitVerification::EnterSentWithoutPlaceholderCheck
375
+ } else {
376
+ SubmitVerification::SubmitConsumptionUnverified
377
+ },
378
+ turn_verification: TurnVerification::NotRequired,
379
+ attempts: 1,
380
+ submit_diagnostics: None,
381
+ })
382
+ }
383
+
384
+ fn native_submit(
385
+ &self,
386
+ evidence: &mut NativeInputEvidence,
387
+ observer: Option<&dyn SubmitObserver>,
388
+ ) -> Result<(), TransportError> {
389
+ let frame = self.native_frame(evidence)?;
390
+ if !frame.pending(evidence) {
391
+ return Err(refused("composer_changed_before_enter"));
392
+ }
393
+ evidence.phase = NativeInputPhase::MayHaveSubmitted;
394
+ self.native_record(evidence)?;
395
+ self.run_inject_stage(
396
+ &tmux_send_submit_argv(&PaneId::new(&evidence.binding.pane_id), Key::Enter),
397
+ InjectStage::Submit,
398
+ )?;
399
+ notify_submit_observer(observer);
400
+ evidence.phase = NativeInputPhase::Submitted;
401
+ self.native_record(evidence)
402
+ }
403
+
404
+ pub(super) fn rescue_native_input(
405
+ &self,
406
+ evidence: &NativeInputEvidence,
407
+ authorize: &dyn Fn() -> bool,
408
+ observer: Option<&dyn SubmitObserver>,
409
+ ) -> Result<EnterRescueOutcome, TransportError> {
410
+ let _lock = match acquire_required(PaneInputLockRequest {
411
+ workspace: self.event_workspace.as_deref(),
412
+ target_key: &evidence.binding.pane_id,
413
+ operation: "enter_rescue",
414
+ }) {
415
+ Some(lock) => lock,
416
+ None => {
417
+ return Ok(EnterRescueOutcome::NoEffect {
418
+ reason: "input_lock",
419
+ reobserve: true,
420
+ })
421
+ }
422
+ };
423
+ if !evidence.request.supported() || evidence.phase < NativeInputPhase::Pasted {
424
+ return Ok(EnterRescueOutcome::NoEffect {
425
+ reason: "proof_missing",
426
+ reobserve: false,
427
+ });
428
+ }
429
+ let frame = self.native_frame(evidence)?;
430
+ if frame.accepted(evidence) {
431
+ return Ok(EnterRescueOutcome::Consumed);
432
+ }
433
+ if frame.busy {
434
+ return Ok(EnterRescueOutcome::NoEffect {
435
+ reason: "busy",
436
+ reobserve: true,
437
+ });
438
+ }
439
+ if !frame.pending(evidence) {
440
+ return Ok(EnterRescueOutcome::NoEffect {
441
+ reason: "residue_mismatch",
442
+ reobserve: false,
443
+ });
444
+ }
445
+ self.stable_frame(evidence, false)?;
446
+ if !authorize() {
447
+ return Ok(EnterRescueOutcome::NoEffect {
448
+ reason: "canonical_changed",
449
+ reobserve: false,
450
+ });
451
+ }
452
+ let mut current = evidence.clone();
453
+ current.phase = NativeInputPhase::Seen;
454
+ self.native_record(&current)?;
455
+ self.native_submit(&mut current, observer)?;
456
+ let accepted = self.observe_native_acceptance(&current)?;
457
+ if accepted {
458
+ current.phase = NativeInputPhase::Accepted;
459
+ self.native_record(&current)?;
460
+ }
461
+ Ok(EnterRescueOutcome::Entered { accepted })
462
+ }
463
+ }
464
+
465
+ fn owned_buffer(evidence: &NativeInputEvidence) -> String {
466
+ format!(
467
+ "ta-owned-{}-{}",
468
+ evidence.request.message_id,
469
+ digest(&format!(
470
+ "{}:{}:{}",
471
+ evidence.binding.binding_nonce, evidence.binding.pane_id, evidence.payload_sha256
472
+ ))
473
+ )
474
+ }
475
+
476
+ fn capsule_labels(provider: Provider, text: &str) -> Vec<String> {
477
+ if provider == Provider::Pi {
478
+ return text
479
+ .match_indices("[paste #")
480
+ .filter_map(|(start, _)| {
481
+ let end = text[start..].find(']')? + start;
482
+ pi_paste_capsule(&text[start..=end]).map(str::to_owned)
483
+ })
484
+ .collect();
485
+ }
486
+ text.lines()
487
+ .filter_map(|line| {
488
+ let start = line.find("[Pasted text #")?;
489
+ let end = line[start..].find(']')? + start;
490
+ let label = &line[start..=end];
491
+ (label.len() <= 80).then(|| label.to_string())
492
+ })
493
+ .collect()
494
+ }