@team-agent/installer 0.5.117 → 0.5.119

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.
@@ -0,0 +1,166 @@
1
+ //! Independent watchdog receipts. Registration is unconditional; key authority is not.
2
+
3
+ use serde::{Deserialize, Serialize};
4
+ use sha2::{Digest, Sha256};
5
+
6
+ use crate::model::enums::Provider;
7
+
8
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9
+ pub struct EnterRescueContext {
10
+ pub message_id: String,
11
+ pub owner_team_id: String,
12
+ pub recipient: String,
13
+ pub payload_sha256: String,
14
+ pub target: Option<EnterRescueTarget>,
15
+ pub proof: Option<EnterRescueProof>,
16
+ }
17
+
18
+ impl EnterRescueContext {
19
+ pub fn marker(&self) -> String {
20
+ format!("[team-agent-token:{}]", self.message_id)
21
+ }
22
+
23
+ /// Binds an opaque-paste receipt to this message and this original carrier.
24
+ pub fn scope_sha256(&self) -> Option<String> {
25
+ let bytes = serde_json::to_vec(&(
26
+ &self.message_id,
27
+ &self.owner_team_id,
28
+ &self.recipient,
29
+ &self.payload_sha256,
30
+ &self.target,
31
+ ))
32
+ .ok()?;
33
+ Some(format!("{:x}", Sha256::digest(bytes)))
34
+ }
35
+
36
+ pub fn valid_proof(&self) -> bool {
37
+ self.proof.as_ref().is_some_and(|proof| {
38
+ proof.valid()
39
+ && proof.owned_capsule.as_ref().is_none_or(|capsule| {
40
+ capsule.valid() && self.scope_sha256().as_ref() == Some(&capsule.scope_sha256)
41
+ })
42
+ })
43
+ }
44
+
45
+ pub fn valid_message(&self) -> bool {
46
+ self.message_id.starts_with("msg_")
47
+ && self
48
+ .message_id
49
+ .bytes()
50
+ .all(|b| b.is_ascii_alphanumeric() || b == b'_')
51
+ && !self.owner_team_id.is_empty()
52
+ && !self.recipient.is_empty()
53
+ && self.recipient != "leader"
54
+ && valid_digest(&self.payload_sha256)
55
+ }
56
+ }
57
+
58
+ /// `selected` records canonical original routing, not a late adoption of a pane.
59
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60
+ pub struct EnterRescueTarget {
61
+ pub provider: Provider,
62
+ pub endpoint: String,
63
+ pub pane_id: String,
64
+ pub pane_pid: u32,
65
+ pub pane_height: u32,
66
+ pub carrier_birth: String,
67
+ pub binding_nonce: String,
68
+ pub session: String,
69
+ pub window: String,
70
+ }
71
+
72
+ impl EnterRescueTarget {
73
+ pub fn selected(
74
+ provider: Provider,
75
+ pane_id: String,
76
+ pane_pid: u32,
77
+ session: String,
78
+ window: String,
79
+ ) -> Self {
80
+ Self {
81
+ provider,
82
+ pane_id,
83
+ pane_pid,
84
+ session,
85
+ window,
86
+ endpoint: String::new(),
87
+ pane_height: 0,
88
+ carrier_birth: String::new(),
89
+ binding_nonce: String::new(),
90
+ }
91
+ }
92
+
93
+ pub fn same_selection(&self, other: &Self) -> bool {
94
+ self.provider == other.provider
95
+ && self.pane_id == other.pane_id
96
+ && self.pane_pid == other.pane_pid
97
+ && self.session == other.session
98
+ && self.window == other.window
99
+ }
100
+
101
+ pub fn bound(&self) -> bool {
102
+ std::path::Path::new(&self.endpoint).is_absolute()
103
+ && self.pane_id.starts_with('%')
104
+ && self.pane_pid > 1
105
+ && self.pane_height >= 10
106
+ && !self.carrier_birth.is_empty()
107
+ && !self.binding_nonce.is_empty()
108
+ && !self.session.is_empty()
109
+ && !self.window.is_empty()
110
+ }
111
+ }
112
+
113
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114
+ pub struct EnterRescueProof {
115
+ pub ui_identity: String,
116
+ pub footer_sha256: String,
117
+ pub visible_marker: bool,
118
+ pub transcript_marker_absent: bool,
119
+ pub owned_capsule: Option<OwnedCapsule>,
120
+ }
121
+
122
+ impl EnterRescueProof {
123
+ pub fn valid(&self) -> bool {
124
+ !self.ui_identity.is_empty()
125
+ && valid_digest(&self.footer_sha256)
126
+ && (self.visible_marker || self.owned_capsule.as_ref().is_some_and(OwnedCapsule::valid))
127
+ }
128
+ }
129
+
130
+ /// Only a prior empty-baseline/single-owned-paste transition may create this.
131
+ /// Snapshotting a visible capsule label alone never creates ownership.
132
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133
+ pub struct OwnedCapsule {
134
+ pub scope_sha256: String,
135
+ pub label: String,
136
+ pub baseline_sha256: String,
137
+ pub baseline_capsules: Vec<String>,
138
+ pub observed_after_owned_paste: bool,
139
+ }
140
+
141
+ impl OwnedCapsule {
142
+ pub fn valid(&self) -> bool {
143
+ self.observed_after_owned_paste
144
+ && valid_digest(&self.scope_sha256)
145
+ && !self.label.is_empty()
146
+ && self.label.len() <= 80
147
+ && valid_digest(&self.baseline_sha256)
148
+ && !self.baseline_capsules.contains(&self.label)
149
+ }
150
+ }
151
+
152
+ fn valid_digest(value: &str) -> bool {
153
+ value.len() == 64 && value.bytes().all(|b| b.is_ascii_hexdigit())
154
+ }
155
+
156
+ #[derive(Debug, Clone, PartialEq, Eq)]
157
+ pub enum EnterRescueOutcome {
158
+ Consumed,
159
+ NoEffect {
160
+ reason: &'static str,
161
+ reobserve: bool,
162
+ },
163
+ Entered {
164
+ accepted: bool,
165
+ },
166
+ }
@@ -131,7 +131,7 @@ pub(crate) fn record_evidence(evidence: &NativeInputEvidence) {
131
131
  }
132
132
 
133
133
  #[derive(Debug, Clone, PartialEq, Eq)]
134
- pub(crate) struct InputFrame {
134
+ pub struct InputFrame {
135
135
  pub composer: String,
136
136
  pub transcript: String,
137
137
  pub identity: String,
@@ -195,6 +195,10 @@ fn input_digest(text: &str) -> String {
195
195
  format!("{:x}", Sha256::digest(text.as_bytes()))
196
196
  }
197
197
 
198
+ pub(crate) fn supports_input_observation(provider: Provider) -> bool {
199
+ matches!(provider, Provider::Pi | Provider::CursorAgent)
200
+ }
201
+
198
202
  pub(crate) fn inspect_input(
199
203
  provider: Provider,
200
204
  text: &str,
@@ -202,16 +206,27 @@ pub(crate) fn inspect_input(
202
206
  ) -> Option<InputFrame> {
203
207
  match provider {
204
208
  Provider::Pi => pi_input(text, pane_height),
205
- Provider::CursorAgent => cursor_input(text),
209
+ Provider::CursorAgent => cursor_input(text, pane_height),
206
210
  _ => None,
207
211
  }
208
212
  }
209
213
 
210
- fn cursor_input(text: &str) -> Option<InputFrame> {
214
+ fn cursor_input(text: &str, pane_height: u32) -> Option<InputFrame> {
211
215
  use team_agent_contract::contract::delivery::InputSurface;
216
+ if pane_height == 0 {
217
+ return None;
218
+ }
219
+ let text_rows: Vec<_> = text.lines().collect();
220
+ // Only the current viewport authorizes input; retain full history below
221
+ // solely for same-message transcript correlation.
222
+ let viewport_start = text_rows.len().saturating_sub(pane_height as usize);
223
+ let viewport = text_rows[viewport_start..].join("\n");
212
224
  let (surface, rows, capsule, busy) =
213
- team_agent_contract::providers::cursor::interaction::inspect_composer(text)?;
214
- let first = rows.first()?.strip_prefix("→ ")?;
225
+ team_agent_contract::providers::cursor::interaction::inspect_legacy_composer(&viewport)?;
226
+ let first = match *rows.first()? {
227
+ "→" => "",
228
+ row => row.strip_prefix("→ ")?,
229
+ };
215
230
  let footer = rows.iter().rposition(|row| {
216
231
  row.strip_suffix("Run Everything").is_some_and(|prefix| {
217
232
  !prefix.trim().is_empty() && !prefix.trim_start().starts_with("→ ")
@@ -228,9 +243,13 @@ fn cursor_input(text: &str) -> Option<InputFrame> {
228
243
  if tail.is_empty() {
229
244
  return None;
230
245
  }
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")));
246
+ // Startup history can leave Tail(200) between Ready/Pasted/Accepted. The
247
+ // immutable carrier birth/nonce remains in NativeInputBinding; this UI
248
+ // context is the current native cwd/footer tail, not a version attestation.
249
+ let identity = format!(
250
+ "cursor-current-composer-v1:{}",
251
+ input_digest(&tail.join("\n"))
252
+ );
234
253
  let footer_row = rows[footer]
235
254
  .split_whitespace()
236
255
  .collect::<Vec<_>>()
@@ -247,10 +266,9 @@ fn cursor_input(text: &str) -> Option<InputFrame> {
247
266
  .collect::<Vec<_>>()
248
267
  .join("\n")
249
268
  };
250
- let text_rows: Vec<_> = text.lines().collect();
251
269
  let arrow = text_rows
252
270
  .iter()
253
- .rposition(|line| line.trim().starts_with("→ "))?;
271
+ .rposition(|line| line.trim() == "→" || line.trim().starts_with("→ "))?;
254
272
  Some(InputFrame {
255
273
  composer,
256
274
  transcript: text.lines().take(arrow).collect::<Vec<_>>().join("\n"),
@@ -544,6 +562,114 @@ mod tests {
544
562
  }
545
563
  }
546
564
 
565
+ // Native Issue #328 current-composer rows; transcript removed and private
566
+ // cwd replaced with /owned/project. Startup banner is genuinely absent
567
+ // from the owned pane's Tail(200), not a fabricated invalid version.
568
+ const CURSOR_FOLLOWUP: &str =
569
+ include_str!("../../tests/fixtures/cursor-followup-headerless.txt");
570
+
571
+ #[test]
572
+ fn native_input_cursor_clean_followup_does_not_require_scrolled_startup_banner() {
573
+ let ready = inspect_input(Provider::CursorAgent, CURSOR_FOLLOWUP).unwrap();
574
+ assert!(ready.empty());
575
+ assert!(!ready.busy);
576
+ assert!(ready.capsule.is_none());
577
+ let header = format!("Cursor Agent\nv2026.10.14-0f9edde\n{CURSOR_FOLLOWUP}");
578
+ let with_header = inspect_input(Provider::CursorAgent, &header).unwrap();
579
+ assert!(with_header.empty() && !with_header.busy);
580
+ assert_eq!(ready.footer, with_header.footer);
581
+ assert_eq!(
582
+ ready.identity, with_header.identity,
583
+ "startup history is not a new UI identity"
584
+ );
585
+ }
586
+
587
+ #[test]
588
+ fn native_input_cursor_headerless_followup_retains_draft_busy_and_footer_fences() {
589
+ let label = "[Pasted text #2 +25 lines]";
590
+ let folded = CURSOR_FOLLOWUP.replace("Add a follow-up", label);
591
+ let draft = inspect_input(Provider::CursorAgent, &folded).unwrap();
592
+ assert!(!draft.empty());
593
+ assert_eq!(draft.capsule.as_deref(), Some(label));
594
+ let dirty = CURSOR_FOLLOWUP.replace("Add a follow-up", "Add a follow-up draft");
595
+ assert!(!inspect_input(Provider::CursorAgent, &dirty)
596
+ .unwrap()
597
+ .empty());
598
+ for dirty in [
599
+ CURSOR_FOLLOWUP.replace("Add a follow-up", "Add a follow-up\nforeign draft"),
600
+ CURSOR_FOLLOWUP.replace("Add a follow-up", &format!("{label}\nforeign draft")),
601
+ ] {
602
+ assert!(inspect_input(Provider::CursorAgent, &dirty).is_none());
603
+ }
604
+ let busy = CURSOR_FOLLOWUP.replace("Add a follow-up", "Add a follow-up ctrl+c to stop");
605
+ assert!(inspect_input(Provider::CursorAgent, &busy).unwrap().busy);
606
+ let working = format!("⠘⠆ Working\n{CURSOR_FOLLOWUP}");
607
+ assert!(inspect_input(Provider::CursorAgent, &working).unwrap().busy);
608
+ for unknown in [
609
+ "→ Add a follow-up\n".to_string(),
610
+ CURSOR_FOLLOWUP.replace("Run Everything", "unknown footer"),
611
+ CURSOR_FOLLOWUP.replace(" /owned/project\n", ""),
612
+ ] {
613
+ assert!(inspect_input(Provider::CursorAgent, &unknown).is_none());
614
+ }
615
+ }
616
+
617
+ #[test]
618
+ fn native_input_cursor_real_expanded_followup_is_nonempty_current_marker_not_capsule() {
619
+ let expanded = include_str!("../../tests/fixtures/cursor-followup-expanded.txt");
620
+ let marker = "[team-agent-token:msg_one]";
621
+ for prefix in [
622
+ "".to_string(),
623
+ format!("Cursor Agent\nv2026.10.14-0f9edde\n{CURSOR_FOLLOWUP}"),
624
+ ] {
625
+ let text = format!("{prefix}{expanded}");
626
+ let frame = inspect_input(Provider::CursorAgent, &text).unwrap();
627
+ assert!(!frame.empty() && !frame.busy);
628
+ assert!(frame.has_marker(marker));
629
+ assert!(frame.capsule.is_none());
630
+ assert!(!frame.transcript.contains(marker));
631
+ let changed = text.replace("/owned/project", "/different/project");
632
+ assert_ne!(
633
+ frame.identity,
634
+ inspect_input(Provider::CursorAgent, &changed)
635
+ .unwrap()
636
+ .identity
637
+ );
638
+ }
639
+ let duplicate = expanded.replace(marker, &format!("{marker}\n{marker}"));
640
+ assert!(!inspect_input(Provider::CursorAgent, &duplicate)
641
+ .unwrap()
642
+ .has_marker(marker));
643
+ let busy = expanded.replace(" →", "⠘⠆ Working\n →");
644
+ assert!(inspect_input(Provider::CursorAgent, &busy).unwrap().busy);
645
+ for unknown in [
646
+ "→\nGrok 4.7 High Run Everything\n/owned/project\n".to_string(),
647
+ "→\nforeign draft\n→ Add a follow-up\nGrok 4.7 High Run Everything\n/owned/project\n"
648
+ .to_string(),
649
+ expanded.replace("Run Everything", "unknown footer"),
650
+ expanded.replace(" /owned/project\n", ""),
651
+ format!("{expanded}{}", "unknown current row\n".repeat(24)),
652
+ ] {
653
+ assert!(super::inspect_input(Provider::CursorAgent, &unknown, 24).is_none());
654
+ }
655
+ }
656
+
657
+ #[test]
658
+ fn native_input_cursor_requires_composer_in_current_viewport_not_old_history() {
659
+ for header in ["", "Cursor Agent\nv2026.10.14-0f9edde\n"] {
660
+ let hidden = format!(
661
+ "{header}{CURSOR_FOLLOWUP}{}",
662
+ "unknown current row\n".repeat(24)
663
+ );
664
+ assert!(super::inspect_input(Provider::CursorAgent, &hidden, 24).is_none());
665
+ }
666
+ assert!(super::inspect_input(Provider::CursorAgent, CURSOR_FOLLOWUP, 0).is_none());
667
+ let current = super::inspect_input(Provider::CursorAgent, CURSOR_FOLLOWUP, 24).unwrap();
668
+ let changed = CURSOR_FOLLOWUP.replace("/owned/project", "/different/project");
669
+ let changed = super::inspect_input(Provider::CursorAgent, &changed, 24).unwrap();
670
+ assert_ne!(current.identity, changed.identity);
671
+ }
672
+
547
673
  #[test]
548
674
  fn native_input_cursor_separates_folded_draft_from_history_and_busy() {
549
675
  let capsule = "[Pasted text #1 +25 lines]";
@@ -57,6 +57,8 @@ struct OfflineState {
57
57
  /// answer keyed by pane id. Used by leader provider health tests.
58
58
  pane_current_commands: BTreeMap<String, String>,
59
59
  session_owner: Option<super::SessionOwner>,
60
+ native_input_observation: Option<super::native_input::InputFrame>,
61
+ native_input_error: Option<String>,
60
62
  }
61
63
 
62
64
  impl Default for OfflineState {
@@ -85,6 +87,8 @@ impl Default for OfflineState {
85
87
  capture_ranges: Vec::new(),
86
88
  pane_current_commands: BTreeMap::new(),
87
89
  session_owner: None,
90
+ native_input_observation: None,
91
+ native_input_error: None,
88
92
  }
89
93
  }
90
94
  }
@@ -95,6 +99,16 @@ pub struct OfflineTransport {
95
99
  }
96
100
 
97
101
  impl OfflineTransport {
102
+ pub fn with_native_input_observation(self, frame: super::native_input::InputFrame) -> Self {
103
+ self.with_state(|state| state.native_input_observation = Some(frame));
104
+ self
105
+ }
106
+
107
+ pub fn with_native_input_error(self, reason: &str) -> Self {
108
+ self.with_state(|state| state.native_input_error = Some(reason.into()));
109
+ self
110
+ }
111
+
98
112
  pub fn new() -> Self {
99
113
  Self::default()
100
114
  }
@@ -434,6 +448,17 @@ impl OfflineTransport {
434
448
  }
435
449
 
436
450
  impl Transport for OfflineTransport {
451
+ fn observe_native_input(
452
+ &self,
453
+ _target: &Target,
454
+ _provider: crate::model::enums::Provider,
455
+ ) -> Result<Option<super::native_input::InputFrame>, TransportError> {
456
+ self.with_state(|state| match &state.native_input_error {
457
+ Some(reason) => Err(TransportError::NativeInputNotReady { reason: reason.clone() }),
458
+ None => Ok(state.native_input_observation.clone()),
459
+ })
460
+ }
461
+
437
462
  fn kind(&self) -> BackendKind {
438
463
  BackendKind::Tmux
439
464
  }
@@ -67,6 +67,7 @@ use std::path::{Path, PathBuf};
67
67
  use serde::{Deserialize, Serialize};
68
68
  use thiserror::Error;
69
69
 
70
+ pub mod enter_rescue;
70
71
  pub mod native_input;
71
72
 
72
73
  #[cfg(test)]
@@ -989,6 +990,39 @@ pub trait Transport: Send + Sync {
989
990
  Ok(report)
990
991
  }
991
992
 
993
+ /// Observation only; None means this backend/provider has no native parser.
994
+ /// An implemented parser with an incomplete frame returns an error, not absence.
995
+ fn observe_native_input(
996
+ &self,
997
+ _target: &Target,
998
+ _provider: crate::model::enums::Provider,
999
+ ) -> Result<Option<native_input::InputFrame>, TransportError> {
1000
+ Ok(None)
1001
+ }
1002
+
1003
+ /// Snapshot the original attempted target without submitting or adopting a capsule.
1004
+ fn snapshot_enter_rescue(
1005
+ &self,
1006
+ _target: &Target,
1007
+ context: &enter_rescue::EnterRescueContext,
1008
+ _rendered_payload: &str,
1009
+ ) -> Result<enter_rescue::EnterRescueContext, TransportError> {
1010
+ Ok(context.clone())
1011
+ }
1012
+
1013
+ /// Independent delayed Enter-only watchdog, not native-input admission.
1014
+ fn rescue_enter_guarded(
1015
+ &self,
1016
+ _context: &enter_rescue::EnterRescueContext,
1017
+ _authorize: &dyn Fn() -> bool,
1018
+ _observer: Option<&dyn SubmitObserver>,
1019
+ ) -> Result<enter_rescue::EnterRescueOutcome, TransportError> {
1020
+ Ok(enter_rescue::EnterRescueOutcome::NoEffect {
1021
+ reason: "unsupported_backend",
1022
+ reobserve: false,
1023
+ })
1024
+ }
1025
+
992
1026
  /// Enter-only recovery; unsupported backends have an explicit zero-effect refusal.
993
1027
  fn rescue_enter(&self, _evidence: &native_input::NativeInputEvidence, _authorize: &dyn Fn() -> bool, _observer: Option<&dyn SubmitObserver>)
994
1028
  -> Result<native_input::EnterRescueOutcome, TransportError>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.5.117",
3
+ "version": "0.5.119",
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.117",
24
- "@team-agent/cli-darwin-x64": "0.5.117",
25
- "@team-agent/cli-linux-x64": "0.5.117"
23
+ "@team-agent/cli-darwin-arm64": "0.5.119",
24
+ "@team-agent/cli-darwin-x64": "0.5.119",
25
+ "@team-agent/cli-linux-x64": "0.5.119"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",