@team-agent/installer 0.5.116 → 0.5.118

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.
@@ -174,6 +174,9 @@ impl Transport for DeliverOkTransport {
174
174
  fn kind(&self) -> BackendKind {
175
175
  BackendKind::Tmux
176
176
  }
177
+ fn supports_owned_native_input(&self) -> bool {
178
+ true
179
+ }
177
180
  fn spawn_first(
178
181
  &self,
179
182
  _s: &SessionName,
@@ -201,6 +204,8 @@ impl Transport for DeliverOkTransport {
201
204
  _s: Key,
202
205
  _b: bool,
203
206
  ) -> Result<InjectReport, TransportError> {
207
+ assert!(crate::transport::native_input::current_request().is_none(),
208
+ "ordinary sends must use v0.5.113 direct input, not pre-paste idle admission");
204
209
  Ok(InjectReport {
205
210
  stage_reached: crate::transport::InjectStage::Submit,
206
211
  inject_verification: crate::transport::InjectVerification::CaptureContainsToken,
@@ -344,13 +349,10 @@ fn spine_delivery_skips_queued_statuses() {
344
349
  );
345
350
  }
346
351
 
347
- // #2 (CONTRACT, corrected) — busy recipient = LIFECYCLE status=="busy" → emit ONE send.deferred_busy
348
- // {recipient,reason:"recipient_busy"} and do NOT deliver (row stays 'accepted'). golden delivery.py:491
349
- // gates on `state.agents[recipient].status == "busy"`, NOT on turn-level agent_health=WORKING.
350
- // (The prior version of this test seeded agent_health=WORKING + status="running" and asserted deferral,
351
- // misreading golden — that stale lock is the root of the deferred_busy regression; corrected here.)
352
+ // Input is unconditional: native CLIs queue messages while working. Activity
353
+ // metadata must not prevent a pending message from reaching paste + Enter.
352
354
  #[test]
353
- fn spine_delivery_busy_recipient_defers_with_event() {
355
+ fn spine_delivery_busy_recipient_is_injected_without_idle_deferral() {
354
356
  let ws = tmp_ws("deliver-busy");
355
357
  let store = store_for(&ws);
356
358
  let log = EventLog::new(&ws);
@@ -361,33 +363,30 @@ fn spine_delivery_busy_recipient_defers_with_event() {
361
363
 
362
364
  let delivered = deliver_pending_messages(&ws, &state, &DeliverOkTransport, &log).unwrap();
363
365
 
364
- assert!(
365
- !delivered.contains(&mid),
366
- "a busy recipient's message must NOT be delivered"
367
- );
368
- assert_eq!(
369
- message_status_of(&store, &mid),
370
- "accepted",
371
- "the row must stay 'accepted' (no-drop, left for a later tick)"
372
- );
366
+ assert!(delivered.contains(&mid));
367
+ assert_eq!(message_status_of(&store, &mid), "delivered");
373
368
  let events = read_event_log(&ws);
374
369
  let deferred: Vec<_> = events
375
370
  .iter()
376
371
  .filter(|e| e.get("event").and_then(|v| v.as_str()) == Some("send.deferred_busy"))
377
372
  .collect();
378
- assert_eq!(
379
- deferred.len(),
380
- 1,
381
- "exactly one send.deferred_busy event expected; got {events:?}"
382
- );
383
- assert_eq!(
384
- deferred[0].get("recipient").and_then(|v| v.as_str()),
385
- Some("w1")
386
- );
387
- assert_eq!(
388
- deferred[0].get("reason").and_then(|v| v.as_str()),
389
- Some("recipient_busy")
390
- );
373
+ assert!(deferred.is_empty());
374
+ }
375
+
376
+ #[test]
377
+ fn spine_delivery_pi_cursor_uses_v113_direct_input_without_idle_scope() {
378
+ for provider in ["pi", "cursor_agent"] {
379
+ let ws = tmp_ws(&format!("direct-input-{provider}"));
380
+ let store = store_for(&ws);
381
+ let log = EventLog::new(&ws);
382
+ let mid = store
383
+ .create_message(Some("t"), "leader", "w1", "hi", None, true, None)
384
+ .unwrap();
385
+ let state = serde_json::json!({"agents": {"w1": {"provider": provider, "status": "running"}}});
386
+ let delivered = deliver_pending_messages(&ws, &state, &DeliverOkTransport, &log).unwrap();
387
+ assert!(delivered.contains(&mid), "{provider}");
388
+ assert_eq!(message_status_of(&store, &mid), "delivered");
389
+ }
391
390
  }
392
391
 
393
392
  // CONTRACT (shared-root, real-machine-driven; golden = correct-behavior baseline): an ALIVE worker
@@ -108,6 +108,7 @@ pub enum ScheduledKind {
108
108
  Send,
109
109
  HealthPing,
110
110
  TrustRetry,
111
+ EnterRescue,
111
112
  }
112
113
 
113
114
  /// classifier 产物 status (card §48;消费 step 8 classifier)。
@@ -185,6 +185,53 @@ impl<'a> StateRepository<'a> {
185
185
  bounded_team_view(&committed, team_key)
186
186
  }
187
187
 
188
+ /// Commit a messaging delta against its exact canonical owner under state-save.
189
+ /// Unlike ordinary compatibility writes, owner loss cannot select raw/default
190
+ /// state, and a failed receiver check cannot be reapplied to another team.
191
+ pub(crate) fn commit_messaging_checked<F>(
192
+ &self,
193
+ intent: StateWriteIntent<'_>,
194
+ update: F,
195
+ ) -> Result<(), StateError>
196
+ where
197
+ F: FnOnce(&mut Value) -> Result<(), StateError>,
198
+ {
199
+ let StateWriteIntent::MessagingDeliveryState { owner_team_id: Some(team) } = intent else {
200
+ return Err(StateError::SaveFailed("checked messaging requires an owner".into()));
201
+ };
202
+ #[cfg(test)]
203
+ test_support::before_commit();
204
+ super::persist::update_runtime_state(self.workspace, |latest| {
205
+ if team.is_empty() || !matches!(
206
+ super::projection::resolve_owner_team_id(latest, team),
207
+ super::projection::OwnerTeamResolution::Canonical(key) if key == team
208
+ ) {
209
+ return Err(StateError::SaveConflict("messaging owner changed before commit".into()));
210
+ }
211
+ let nested = latest.get("teams").and_then(Value::as_object)
212
+ .is_some_and(|teams| teams.contains_key(team));
213
+ let mut selected = if nested {
214
+ super::projection::project_top_level_view(latest, team)
215
+ } else if legacy_single_team_state(latest)
216
+ && (latest.get("active_team_key").and_then(Value::as_str) == Some(team)
217
+ || super::projection::team_state_key(latest) == team
218
+ || latest.get("session_name").and_then(Value::as_str)
219
+ .and_then(|session| session.strip_prefix("team-")) == Some(team))
220
+ {
221
+ latest.clone()
222
+ } else {
223
+ return Err(StateError::SaveConflict("messaging owner no longer exists".into()));
224
+ };
225
+ update(&mut selected)?;
226
+ Ok(if nested {
227
+ super::projection::merge_committed_team(latest, &selected, team)
228
+ } else {
229
+ selected
230
+ })
231
+ })?;
232
+ Ok(())
233
+ }
234
+
188
235
  /// Atomically replace only the fork target row in the selected team.
189
236
  pub(crate) fn commit_fork_agent(
190
237
  &self,
@@ -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
+ }