@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,594 @@
1
+ //! Owned legacy-tmux composer evidence. No keys, persistence, or provider admission.
2
+
3
+ use std::cell::RefCell;
4
+
5
+ use serde::{Deserialize, Serialize};
6
+
7
+ use crate::model::enums::Provider;
8
+
9
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
10
+ pub struct NativeInputRequest {
11
+ pub message_id: String,
12
+ pub owner_team_id: String,
13
+ pub recipient: String,
14
+ pub provider: Provider,
15
+ pub pane_id: String,
16
+ pub pane_pid: u32,
17
+ pub session: String,
18
+ pub window: String,
19
+ }
20
+
21
+ impl NativeInputRequest {
22
+ pub fn marker(&self) -> String {
23
+ format!("[team-agent-token:{}]", self.message_id)
24
+ }
25
+
26
+ pub fn supported(&self) -> bool {
27
+ matches!(self.provider, Provider::Pi | Provider::CursorAgent)
28
+ && self.message_id.starts_with("msg_")
29
+ && self
30
+ .message_id
31
+ .bytes()
32
+ .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
33
+ && !self.owner_team_id.is_empty()
34
+ && !self.recipient.is_empty()
35
+ && self.recipient != "leader"
36
+ && self.pane_id.starts_with('%')
37
+ && self.pane_pid > 1
38
+ && !self.session.is_empty()
39
+ && !self.window.is_empty()
40
+ }
41
+ }
42
+
43
+ /// Carrier birth and framework pane nonce, not a claim of native executable admission.
44
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45
+ pub struct NativeInputBinding {
46
+ pub endpoint: String,
47
+ pub pane_id: String,
48
+ pub pane_pid: u32,
49
+ pub pane_height: u32,
50
+ pub carrier_birth: String,
51
+ pub binding_nonce: String,
52
+ pub session: String,
53
+ pub window: String,
54
+ }
55
+
56
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
57
+ #[serde(rename_all = "snake_case")]
58
+ pub enum NativeInputPhase {
59
+ Ready,
60
+ MayHavePasted,
61
+ Pasted,
62
+ Seen,
63
+ MayHaveSubmitted,
64
+ Submitted,
65
+ Accepted,
66
+ }
67
+
68
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69
+ pub struct NativeInputEvidence {
70
+ pub request: NativeInputRequest,
71
+ pub binding: NativeInputBinding,
72
+ pub payload_sha256: String,
73
+ pub ui_identity: String,
74
+ pub footer_sha256: String,
75
+ pub baseline_sha256: String,
76
+ pub baseline_capsules: Vec<String>,
77
+ pub phase: NativeInputPhase,
78
+ pub capsule: Option<String>,
79
+ }
80
+
81
+ #[derive(Debug, Clone, PartialEq, Eq)]
82
+ pub enum EnterRescueOutcome {
83
+ Consumed,
84
+ NoEffect {
85
+ reason: &'static str,
86
+ reobserve: bool,
87
+ },
88
+ Entered {
89
+ accepted: bool,
90
+ },
91
+ }
92
+
93
+ type Scope = (Option<NativeInputRequest>, Option<NativeInputEvidence>);
94
+ thread_local! {
95
+ static INPUT_SCOPE: RefCell<Scope> = const { RefCell::new((None, None)) };
96
+ }
97
+
98
+ struct Restore(Option<Scope>);
99
+ impl Drop for Restore {
100
+ fn drop(&mut self) {
101
+ if let Some(previous) = self.0.take() {
102
+ INPUT_SCOPE.with(|slot| *slot.borrow_mut() = previous);
103
+ }
104
+ }
105
+ }
106
+
107
+ /// Existing synchronous delivery owns this scope; no borrowed observer escapes a thread.
108
+ pub fn with_native_input<R>(
109
+ request: Option<NativeInputRequest>,
110
+ f: impl FnOnce() -> R,
111
+ ) -> (R, Option<NativeInputEvidence>) {
112
+ let previous = INPUT_SCOPE.with(|slot| slot.replace((request, None)));
113
+ let restore = Restore(Some(previous));
114
+ let result = f();
115
+ let evidence = INPUT_SCOPE.with(|slot| slot.borrow_mut().1.take());
116
+ drop(restore);
117
+ (result, evidence)
118
+ }
119
+
120
+ pub(crate) fn current_request() -> Option<NativeInputRequest> {
121
+ INPUT_SCOPE.with(|slot| slot.borrow().0.clone())
122
+ }
123
+
124
+ pub(crate) fn record_evidence(evidence: &NativeInputEvidence) {
125
+ INPUT_SCOPE.with(|slot| {
126
+ let mut scope = slot.borrow_mut();
127
+ if scope.0.as_ref() == Some(&evidence.request) {
128
+ scope.1 = Some(evidence.clone());
129
+ }
130
+ });
131
+ }
132
+
133
+ #[derive(Debug, Clone, PartialEq, Eq)]
134
+ pub(crate) struct InputFrame {
135
+ pub composer: String,
136
+ pub transcript: String,
137
+ pub identity: String,
138
+ pub footer: String,
139
+ pub busy: bool,
140
+ pub capsule: Option<String>,
141
+ }
142
+
143
+ impl InputFrame {
144
+ pub fn empty(&self) -> bool {
145
+ self.composer.trim().is_empty()
146
+ }
147
+
148
+ pub fn has_marker(&self, marker: &str) -> bool {
149
+ self.composer
150
+ .lines()
151
+ .map(str::trim)
152
+ .collect::<String>()
153
+ .matches(marker)
154
+ .count()
155
+ == 1
156
+ }
157
+
158
+ pub fn pending(&self, evidence: &NativeInputEvidence) -> bool {
159
+ !self.busy
160
+ && self.identity == evidence.ui_identity
161
+ && input_digest(&self.footer) == evidence.footer_sha256
162
+ && (self.has_marker(&evidence.request.marker())
163
+ || evidence.capsule.as_ref().is_some_and(|capsule| {
164
+ self.capsule.as_ref() == Some(capsule)
165
+ && !evidence.baseline_capsules.contains(capsule)
166
+ }))
167
+ }
168
+
169
+ pub fn accepted(&self, evidence: &NativeInputEvidence) -> bool {
170
+ if !self.empty() || self.identity != evidence.ui_identity {
171
+ return false;
172
+ }
173
+ if evidence.phase < NativeInputPhase::Seen {
174
+ return false;
175
+ }
176
+ let marker = evidence.request.marker();
177
+ let joined: String = self.transcript.lines().map(str::trim).collect();
178
+ if joined.matches(&marker).count() == 1 {
179
+ return true;
180
+ }
181
+ evidence.capsule.as_ref().is_some_and(|capsule| {
182
+ !evidence.baseline_capsules.contains(capsule)
183
+ && self
184
+ .transcript
185
+ .lines()
186
+ .filter(|line| line.trim() == capsule)
187
+ .count()
188
+ == 1
189
+ })
190
+ }
191
+ }
192
+
193
+ fn input_digest(text: &str) -> String {
194
+ use sha2::{Digest, Sha256};
195
+ format!("{:x}", Sha256::digest(text.as_bytes()))
196
+ }
197
+
198
+ pub(crate) fn inspect_input(
199
+ provider: Provider,
200
+ text: &str,
201
+ pane_height: u32,
202
+ ) -> Option<InputFrame> {
203
+ match provider {
204
+ Provider::Pi => pi_input(text, pane_height),
205
+ Provider::CursorAgent => cursor_input(text),
206
+ _ => None,
207
+ }
208
+ }
209
+
210
+ fn cursor_input(text: &str) -> Option<InputFrame> {
211
+ use team_agent_contract::contract::delivery::InputSurface;
212
+ let (surface, rows, capsule, busy) =
213
+ team_agent_contract::providers::cursor::interaction::inspect_composer(text)?;
214
+ let first = rows.first()?.strip_prefix("→ ")?;
215
+ let footer = rows.iter().rposition(|row| {
216
+ row.strip_suffix("Run Everything").is_some_and(|prefix| {
217
+ !prefix.trim().is_empty() && !prefix.trim_start().starts_with("→ ")
218
+ })
219
+ })?;
220
+ if footer == 0 {
221
+ return None;
222
+ }
223
+ let tail: Vec<_> = rows[footer + 1..]
224
+ .iter()
225
+ .filter(|row| !row.is_empty())
226
+ .copied()
227
+ .collect();
228
+ if tail.is_empty() {
229
+ return None;
230
+ }
231
+ let header: Vec<_> = text.lines().map(str::trim).collect();
232
+ let version = header.windows(2).find(|pair| pair[0] == "Cursor Agent")?[1];
233
+ let identity = format!("{version}:{}", input_digest(&tail.join("\n")));
234
+ let footer_row = rows[footer]
235
+ .split_whitespace()
236
+ .collect::<Vec<_>>()
237
+ .join(" ");
238
+ let ready = matches!(surface, InputSurface::ComposerReady | InputSurface::Busy);
239
+ if (ready || capsule.is_some()) && rows[1..footer].iter().any(|row| !row.trim().is_empty()) {
240
+ return None;
241
+ }
242
+ let composer = if ready {
243
+ String::new()
244
+ } else {
245
+ std::iter::once(first)
246
+ .chain(rows[1..footer].iter().copied())
247
+ .collect::<Vec<_>>()
248
+ .join("\n")
249
+ };
250
+ let text_rows: Vec<_> = text.lines().collect();
251
+ let arrow = text_rows
252
+ .iter()
253
+ .rposition(|line| line.trim().starts_with("→ "))?;
254
+ Some(InputFrame {
255
+ composer,
256
+ transcript: text.lines().take(arrow).collect::<Vec<_>>().join("\n"),
257
+ identity,
258
+ footer: footer_row,
259
+ busy,
260
+ capsule: capsule.map(str::to_owned),
261
+ })
262
+ }
263
+
264
+ fn border(line: &str) -> bool {
265
+ line.chars().filter(|character| *character == '─').count() >= 8
266
+ && line.trim_start().starts_with('─')
267
+ }
268
+
269
+ // A complete native update notice can end inside the editor search window,
270
+ // but its opening must precede every possible editor row. Never discard a
271
+ // ruler belonging to the editable region or an incomplete/unknown notice.
272
+ fn pi_closed_update_notice(rows: &[&str], closing: usize, editor_start: usize, top: usize) -> bool {
273
+ let rule = rows[closing].trim();
274
+ if !rule.chars().all(|character| character == '─')
275
+ || !rows[closing + 1..top]
276
+ .iter()
277
+ .all(|row| row.trim().is_empty())
278
+ {
279
+ return false;
280
+ }
281
+ let Some(opening) = rows[..editor_start]
282
+ .iter()
283
+ .rposition(|row| row.trim() == rule)
284
+ else {
285
+ return false;
286
+ };
287
+ let body = &rows[opening + 1..closing];
288
+ body.len() >= 4
289
+ && body[0].trim() == "Package Updates Available"
290
+ && body[1].trim() == "Package updates are available. Run pi update --extensions"
291
+ && body[2].trim() == "Packages:"
292
+ && body[3..].iter().all(|row| {
293
+ row.trim()
294
+ .strip_prefix("- ")
295
+ .is_some_and(|package| !package.trim().is_empty())
296
+ })
297
+ }
298
+
299
+ pub(crate) fn pi_paste_capsule(text: &str) -> Option<&str> {
300
+ fn positive(value: &str) -> bool {
301
+ !value.starts_with('0')
302
+ && value.bytes().all(|byte| byte.is_ascii_digit())
303
+ && value.parse::<u64>().is_ok()
304
+ }
305
+ let label = text.trim();
306
+ let body = label.strip_prefix("[paste #")?.strip_suffix(']')?;
307
+ let (id, size) = body.split_once(' ')?;
308
+ let count = if let Some(lines) = size.strip_prefix('+') {
309
+ lines.strip_suffix(" lines")?
310
+ } else {
311
+ size.strip_suffix(" chars")?
312
+ };
313
+ (positive(id) && positive(count)).then_some(label)
314
+ }
315
+
316
+ fn pi_input(text: &str, pane_height: u32) -> Option<InputFrame> {
317
+ if pane_height < 10 {
318
+ return None;
319
+ }
320
+ let rows: Vec<_> = text.lines().collect();
321
+ let footer = rows
322
+ .iter()
323
+ .rposition(|row| row.contains(" · ") && row.contains("%/"))?;
324
+ if rows[footer + 1..].iter().any(|row| !row.trim().is_empty()) {
325
+ return None;
326
+ }
327
+ let bottom = (0..footer)
328
+ .rev()
329
+ .find(|index| !rows[*index].trim().is_empty())?;
330
+ if !border(rows[bottom]) {
331
+ return None;
332
+ }
333
+ // Current Pi renderer shows at most max(5, floor(pane_rows * .3))
334
+ // editor lines. A task's Unicode ruler is not a native top boundary:
335
+ // ambiguous borders inside that whole possible editor extent refuse.
336
+ let extent = (pane_height as usize * 3 / 10).max(5) + 1;
337
+ let editor_start = bottom.saturating_sub(extent);
338
+ let candidates: Vec<_> = (editor_start..bottom)
339
+ .filter(|index| border(rows[*index]))
340
+ .collect();
341
+ let top = *candidates.last()?;
342
+ if candidates[..candidates.len() - 1]
343
+ .iter()
344
+ .any(|index| !pi_closed_update_notice(&rows, *index, editor_start, top))
345
+ {
346
+ return None;
347
+ }
348
+ let composer = rows[top + 1..bottom].join("\n");
349
+ let transcript = rows[..top].join("\n");
350
+ // Inspect native status adjacent to the editor, never the task body.
351
+ let status = rows[..top]
352
+ .iter()
353
+ .rev()
354
+ .filter(|row| !row.trim().is_empty())
355
+ .take(3)
356
+ .copied()
357
+ .collect::<Vec<_>>()
358
+ .join("\n");
359
+ // Pi embeds its live status/spinner after a 1–3-cell border prefix. Idle
360
+ // (including the observed overflow/title border) has a solid leading run.
361
+ let busy = rows[top]
362
+ .trim_start()
363
+ .chars()
364
+ .take_while(|character| *character == '─')
365
+ .count()
366
+ < 8
367
+ || status.to_ascii_lowercase().contains("esc to interrupt")
368
+ || status.to_ascii_lowercase().contains("ctrl+c to stop");
369
+ let capsule = pi_paste_capsule(&composer).map(str::to_owned);
370
+ Some(InputFrame {
371
+ composer,
372
+ transcript,
373
+ identity: rows[footer].split(" · ").next()?.trim().to_string(),
374
+ footer: String::new(),
375
+ busy,
376
+ capsule,
377
+ })
378
+ }
379
+
380
+ #[cfg(test)]
381
+ mod tests {
382
+ #![allow(clippy::unwrap_used, clippy::panic)]
383
+ use super::*;
384
+
385
+ fn cursor(composer: &str, transcript: &str) -> String {
386
+ format!("Cursor Agent\nv2026.10.01-e373342\n{transcript}\n→ {composer}\nGrok 4.7 High Run Everything\n/owned/project\n")
387
+ }
388
+
389
+ fn pi(composer: &str, transcript: &str) -> String {
390
+ format!("{transcript}\n──────────────── ↑ 20 more ─ runner-luna ─\n{composer}\n────────────────────────\ngpt-6-luna · max · 0%/272k · 0% · $0.00\n")
391
+ }
392
+
393
+ fn inspect_input(provider: Provider, text: &str) -> Option<InputFrame> {
394
+ super::inspect_input(provider, text, 120)
395
+ }
396
+
397
+ #[test]
398
+ fn native_input_scope_restores_nested_and_panicking_calls() {
399
+ let request = NativeInputRequest {
400
+ message_id: "msg_one".into(),
401
+ owner_team_id: "team".into(),
402
+ recipient: "worker".into(),
403
+ provider: Provider::Pi,
404
+ pane_id: "%1".into(),
405
+ pane_pid: 2,
406
+ session: "team".into(),
407
+ window: "worker".into(),
408
+ };
409
+ with_native_input(Some(request.clone()), || {
410
+ assert_eq!(current_request(), Some(request.clone()));
411
+ with_native_input(None, || assert!(current_request().is_none()));
412
+ assert_eq!(current_request(), Some(request));
413
+ let _ = std::panic::catch_unwind(|| with_native_input(None, || panic!("fixture")));
414
+ assert!(current_request().is_some());
415
+ });
416
+ assert!(current_request().is_none());
417
+ }
418
+
419
+ #[test]
420
+ fn native_input_pi_requires_editor_and_footer_not_startup_echo() {
421
+ let marker = "[team-agent-token:msg_one]";
422
+ let draft = pi(
423
+ &format!("{}\n{marker}", "line\n".repeat(26)),
424
+ "Warning: startup",
425
+ );
426
+ let frame = inspect_input(Provider::Pi, &draft).unwrap();
427
+ assert!(!frame.empty());
428
+ assert!(frame.has_marker(marker));
429
+ assert!(!frame.busy);
430
+ assert!(inspect_input(Provider::Pi, &format!("Warning: startup\n{marker}")).is_none());
431
+ assert!(inspect_input(Provider::Pi, &pi("", "old history"))
432
+ .unwrap()
433
+ .empty());
434
+ let busy = pi("draft", "history").replace(
435
+ "──────────────── ↑ 20 more ─ runner-luna ─",
436
+ "── ⠋ Working ─────────────────────",
437
+ );
438
+ assert!(inspect_input(Provider::Pi, &busy).unwrap().busy);
439
+ assert!(inspect_input(
440
+ Provider::Pi,
441
+ &pi("foreign draft\n────────\n", "old history")
442
+ )
443
+ .is_none());
444
+ }
445
+
446
+ const PI_R6_IDLE: &str = include_str!("../../tests/fixtures/pi-r6-idle-24.txt");
447
+
448
+ #[test]
449
+ fn native_input_pi_r6_idle_update_notice_preserves_empty_editor() {
450
+ assert_eq!(
451
+ input_digest(PI_R6_IDLE),
452
+ "40f31e7476f628a471113c89b76a1ace0246e69f53fb7238999205f62b5d20fa"
453
+ );
454
+ let frame = super::inspect_input(Provider::Pi, PI_R6_IDLE, 24).unwrap();
455
+ assert!(frame.empty());
456
+ assert!(!frame.busy);
457
+ assert_eq!(frame.identity, "gpt-6-luna");
458
+ assert!(frame.transcript.contains("Package Updates Available"));
459
+ assert!(!frame.composer.contains("Package Updates Available"));
460
+ }
461
+
462
+ #[test]
463
+ fn native_input_pi_r6_notice_does_not_hide_drafts_or_busy_state() {
464
+ let top = " pi-target ─\n\n";
465
+ let marker = "[team-agent-token:msg_one]";
466
+ let draft = PI_R6_IDLE.replace(top, &format!(" pi-target ─\nforeign draft\n{marker}\n"));
467
+ let frame = super::inspect_input(Provider::Pi, &draft, 24).unwrap();
468
+ assert!(!frame.empty());
469
+ assert!(frame.has_marker(marker));
470
+ let busy = PI_R6_IDLE.replace(
471
+ "──────────────────────────────────────────────────────────────────── pi-target ─",
472
+ "── ⠋ Working ──────────────────────────────────────────────────────────────────",
473
+ );
474
+ let frame = super::inspect_input(Provider::Pi, &busy, 24).unwrap();
475
+ assert!(frame.empty());
476
+ assert!(frame.busy);
477
+ for composer in ["foreign draft\n────────\n", "\n────────\n"]
478
+ {
479
+ let foreign = PI_R6_IDLE.replace(top, &format!(" pi-target ─\n{composer}\n"));
480
+ assert!(super::inspect_input(Provider::Pi, &foreign, 24).is_none());
481
+ }
482
+ }
483
+
484
+ #[test]
485
+ fn native_input_pi_r6_incomplete_or_ambiguous_notice_refuses() {
486
+ for frame in [
487
+ PI_R6_IDLE.replace("Package Updates Available", "Unknown notification"),
488
+ PI_R6_IDLE.replace(" - pi-mcp-adapter", "not a native package row"),
489
+ PI_R6_IDLE.replace("\n\n──────────────────────────────────────────────────────────────────── pi-target", "\nforeign draft\n──────────────────────────────────────────────────────────────────── pi-target"),
490
+ ] {
491
+ assert!(super::inspect_input(Provider::Pi, &frame, 24).is_none());
492
+ }
493
+ // If the complete box itself could be editor content, geometry cannot
494
+ // distinguish it from a pasted imitation: keep the old refusal.
495
+ assert!(super::inspect_input(Provider::Pi, PI_R6_IDLE, 120).is_none());
496
+ }
497
+
498
+ const PI_R7_FOLDED: &str = include_str!("../../tests/fixtures/pi-r7-folded-24.txt");
499
+
500
+ #[test]
501
+ fn native_input_pi_r7_capsule_is_current_composer_not_history() {
502
+ assert_eq!(
503
+ input_digest(PI_R7_FOLDED),
504
+ "b7ea6992c3303743b50714fb55ffc8b91ac5ad168e0acd015428ca5af538ed0e"
505
+ );
506
+ let frame = super::inspect_input(Provider::Pi, PI_R7_FOLDED, 24).unwrap();
507
+ assert!(!frame.empty());
508
+ assert!(!frame.busy);
509
+ assert_eq!(frame.capsule.as_deref(), Some("[paste #1 +30 lines]"));
510
+ assert!(!frame.has_marker("[team-agent-token:msg_one]"));
511
+ let history = format!("[paste #1 +30 lines]\n{PI_R6_IDLE}");
512
+ let frame = super::inspect_input(Provider::Pi, &history, 24).unwrap();
513
+ assert!(frame.empty());
514
+ assert!(frame.capsule.is_none());
515
+ let extra = PI_R7_FOLDED.replace(
516
+ "[paste #1 +30 lines]",
517
+ "[paste #1 +30 lines]\nforeign draft",
518
+ );
519
+ let frame = super::inspect_input(Provider::Pi, &extra, 24).unwrap();
520
+ assert!(!frame.empty());
521
+ assert!(frame.capsule.is_none());
522
+ }
523
+
524
+ #[test]
525
+ fn native_input_pi_capsule_grammar_is_canonical_and_provider_specific() {
526
+ for label in ["[paste #1 +30 lines]", "[paste #2 1234 chars]"] {
527
+ assert_eq!(pi_paste_capsule(label), Some(label));
528
+ }
529
+ for label in [
530
+ "[paste #0 +30 lines]",
531
+ "[paste #01 +30 lines]",
532
+ "[paste #1 +0 lines]",
533
+ "[paste #1 +030 lines]",
534
+ "[paste #18446744073709551616 +30 lines]",
535
+ "[paste #1 +18446744073709551616 lines]",
536
+ "[paste #1 -30 lines]",
537
+ "[paste #1 +30 line]",
538
+ "[paste #1 +30 lines]suffix",
539
+ "prefix[paste #1 +30 lines]",
540
+ "[paste #1 +30 lines]\n[paste #2 +40 lines]",
541
+ "[Pasted text #1 +30 lines]",
542
+ ] {
543
+ assert_eq!(pi_paste_capsule(label), None, "{label}");
544
+ }
545
+ }
546
+
547
+ #[test]
548
+ fn native_input_cursor_separates_folded_draft_from_history_and_busy() {
549
+ let capsule = "[Pasted text #1 +25 lines]";
550
+ let draft = inspect_input(Provider::CursorAgent, &cursor(capsule, "")).unwrap();
551
+ assert!(!draft.empty());
552
+ assert_eq!(draft.capsule.as_deref(), Some(capsule));
553
+ let ready = inspect_input(
554
+ Provider::CursorAgent,
555
+ &cursor("Plan, search, build anything", capsule),
556
+ )
557
+ .unwrap();
558
+ assert!(ready.empty());
559
+ assert!(ready.capsule.is_none());
560
+ let busy = inspect_input(
561
+ Provider::CursorAgent,
562
+ &cursor("Add a follow-up ctrl+c to stop", capsule),
563
+ )
564
+ .unwrap();
565
+ assert!(busy.busy);
566
+ assert!(
567
+ inspect_input(Provider::CursorAgent, &cursor(capsule, "⠘⠆ Working"))
568
+ .unwrap()
569
+ .busy
570
+ );
571
+ assert!(inspect_input(Provider::CursorAgent, "→ [Pasted text #1 +25 lines]").is_none());
572
+ assert!(inspect_input(
573
+ Provider::CursorAgent,
574
+ &cursor("Plan, search, build anything\nextra editable text", "")
575
+ )
576
+ .is_none());
577
+ assert!(inspect_input(
578
+ Provider::CursorAgent,
579
+ &cursor("[Pasted text #1 +25 lines]\nsecond block", "")
580
+ )
581
+ .is_none());
582
+ assert!(inspect_input(
583
+ Provider::CursorAgent,
584
+ "Cursor Agent\nv2026.10.01-e373342\n→ Run Everything\n"
585
+ )
586
+ .is_none());
587
+ let malformed = inspect_input(
588
+ Provider::CursorAgent,
589
+ &cursor("[Pasted text #0 +25 lines]", ""),
590
+ )
591
+ .unwrap();
592
+ assert!(malformed.capsule.is_none());
593
+ }
594
+ }
@@ -67,6 +67,8 @@ use std::path::{Path, PathBuf};
67
67
  use serde::{Deserialize, Serialize};
68
68
  use thiserror::Error;
69
69
 
70
+ pub mod native_input;
71
+
70
72
  #[cfg(test)]
71
73
  pub mod test_support;
72
74
 
@@ -727,6 +729,9 @@ pub enum AttachOutcome {
727
729
  /// transport 的 I/O / 子进程错误。能力性拒绝**不**走这里(用上面的 typed outcome)。
728
730
  #[derive(Debug, Error)]
729
731
  pub enum TransportError {
732
+ /// Guard refused before a paste/key; the owned evidence carries any later effect floor.
733
+ #[error("native input not ready: {reason}")]
734
+ NativeInputNotReady { reason: String },
730
735
  #[error("spawn failed on {backend:?}: {source}")]
731
736
  Spawn {
732
737
  backend: BackendKind,
@@ -796,6 +801,13 @@ pub trait Transport: Send + Sync {
796
801
  false
797
802
  }
798
803
 
804
+ /// This backend implements the owned native composer/submit handshake.
805
+ /// Capability only: no socket discovery, reads, or effects. Unknown/test
806
+ /// backends do not claim a native input protocol by default.
807
+ fn supports_owned_native_input(&self) -> bool {
808
+ false
809
+ }
810
+
799
811
  /// Physical tmux endpoint used by this transport when known. For tmux this is
800
812
  /// either a full `-S` socket path or a `-L` socket name; non-tmux/test
801
813
  /// transports can leave it unknown.
@@ -977,6 +989,13 @@ pub trait Transport: Send + Sync {
977
989
  Ok(report)
978
990
  }
979
991
 
992
+ /// Enter-only recovery; unsupported backends have an explicit zero-effect refusal.
993
+ fn rescue_enter(&self, _evidence: &native_input::NativeInputEvidence, _authorize: &dyn Fn() -> bool, _observer: Option<&dyn SubmitObserver>)
994
+ -> Result<native_input::EnterRescueOutcome, TransportError>
995
+ {
996
+ Ok(native_input::EnterRescueOutcome::NoEffect { reason: "unsupported_backend", reobserve: false })
997
+ }
998
+
980
999
  fn send_keys(&self, target: &Target, keys: &[Key]) -> Result<(), TransportError>;
981
1000
 
982
1001
  fn capture(&self, target: &Target, range: CaptureRange)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.5.115",
3
+ "version": "0.5.117",
4
4
  "description": "npx installer for Team Agent",
5
5
  "keywords": [
6
6
  "codex",
@@ -20,9 +20,9 @@
20
20
  "team-agent-installer": "npm/install.mjs"
21
21
  },
22
22
  "optionalDependencies": {
23
- "@team-agent/cli-darwin-arm64": "0.5.115",
24
- "@team-agent/cli-darwin-x64": "0.5.115",
25
- "@team-agent/cli-linux-x64": "0.5.115"
23
+ "@team-agent/cli-darwin-arm64": "0.5.117",
24
+ "@team-agent/cli-darwin-x64": "0.5.117",
25
+ "@team-agent/cli-linux-x64": "0.5.117"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",