@team-agent/installer 0.5.117 → 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.
package/Cargo.lock CHANGED
@@ -727,7 +727,7 @@ dependencies = [
727
727
 
728
728
  [[package]]
729
729
  name = "team-agent"
730
- version = "0.5.117"
730
+ version = "0.5.118"
731
731
  dependencies = [
732
732
  "anyhow",
733
733
  "chrono",
package/Cargo.toml CHANGED
@@ -11,7 +11,7 @@ exclude = ["crates/team-agent-contract"]
11
11
 
12
12
  [workspace.package]
13
13
  edition = "2021"
14
- version = "0.5.117"
14
+ version = "0.5.118"
15
15
  license = "AGPL-3.0"
16
16
  rust-version = "1.95"
17
17
 
@@ -424,6 +424,26 @@ pub mod lifecycle_port {
424
424
  });
425
425
  if let Some(object) = value.as_object_mut() {
426
426
  object.extend(facts);
427
+ // Only a plain Pi entry can be retried verbatim with this command.
428
+ // Never drop explicit attach/external intent or provider arguments,
429
+ // clear the caller's environment, or adopt the unverified pane.
430
+ if cfg!(unix)
431
+ && provider == Provider::Pi
432
+ && attach == &LeaderLauncherArgs::default()
433
+ {
434
+ let command = "env -u TMUX -u TMUX_PANE team-agent pi";
435
+ object.insert("recovery_command".to_string(), json!(command));
436
+ object.insert(
437
+ ACTION_FIELD.to_string(),
438
+ json!(format!(
439
+ "The inherited TMUX/TMUX_PANE could not be authorized for \
440
+ requested_workspace; this is not an empty-directory error. \
441
+ To explicitly retry from requested_workspace, run `{command}`. \
442
+ This removes the inherited variables only for the new process \
443
+ and does not detach or modify your current tmux pane. {action}"
444
+ )),
445
+ );
446
+ }
427
447
  }
428
448
  value
429
449
  }
@@ -48,9 +48,6 @@ enum ManagedClientAttachMode {
48
48
  SwitchClient,
49
49
  }
50
50
 
51
- const DIFFERENT_TMUX_SERVER_PREFIX: &str =
52
- "managed launcher refuses a different ambient tmux server";
53
-
54
51
  impl PreparedLeaderStart {
55
52
  pub(crate) fn plan(&self) -> &LeaderStartPlan {
56
53
  &self.plan
@@ -141,8 +138,6 @@ pub(crate) fn prepare_leader_start_with_nested_attach(
141
138
  let (ambient_authority, managed_client_attach_mode, managed_exec_provider) =
142
139
  if explicit_external_path || state_external_path {
143
140
  (ambient_pane_authority_preflight(workspace)?, None, false)
144
- } else if workspace_state_is_managed_provider_reentry(workspace) {
145
- (ambient_pane_authority_preflight(workspace)?, None, true)
146
141
  } else {
147
142
  managed_launcher_ambient_route(
148
143
  provider,
@@ -159,7 +154,7 @@ pub(crate) fn prepare_leader_start_with_nested_attach(
159
154
  confirm_attach,
160
155
  attach_session,
161
156
  external_leader,
162
- std::env::var_os("TMUX").is_some(),
157
+ ambient_authority.is_some(),
163
158
  managed_client_attach_mode,
164
159
  managed_exec_provider,
165
160
  allow_nested_attach,
@@ -613,64 +608,49 @@ fn managed_launcher_ambient_route(
613
608
  ),
614
609
  PrepareLeaderStartError,
615
610
  > {
616
- let Some(tmux) = std::env::var_os("TMUX") else {
617
- return Ok((
618
- None,
619
- Some(ManagedClientAttachMode::AttachSession),
620
- false,
621
- ));
611
+ let isolated = || (None, Some(ManagedClientAttachMode::AttachSession), false);
612
+ let authority = match ambient_pane_authority_preflight(workspace) {
613
+ Ok(Some(authority)) => authority,
614
+ Ok(None) => return Ok(isolated()),
615
+ Err(PrepareLeaderStartError::PaneAuthorityRefused(_)) if !allow_nested_attach => {
616
+ // An inherited tuple cannot authorize adoption, but an ordinary
617
+ // managed launch can still create its own workspace pane. Keep
618
+ // explicit external/attach and nested-attach intent fail-closed.
619
+ return Ok(isolated());
620
+ }
621
+ Err(error) => return Err(error),
622
622
  };
623
- let tmux = tmux.into_string().map_err(|_| {
624
- PrepareLeaderStartError::PaneAuthorityRefused(ambient_tmux_endpoint_refusal(
625
- workspace,
626
- pane_refusal::AmbientTmuxEndpointUnavailableCause::TmuxValueNotUnicode,
627
- ))
628
- })?;
629
- let observed_endpoint = validated_ambient_tmux_endpoint(workspace, &tmux)
630
- .map_err(PrepareLeaderStartError::PaneAuthorityRefused)?;
631
- if provider == Provider::Pi && provider_args.is_empty() && !allow_nested_attach {
632
- // Pi's zero-argument managed entry already owns this verified pane as its
633
- // user-facing terminal. Replacing the current process is safe on either
634
- // the workspace server or another server; switching/attaching would move
635
- // the user away from the positively verified workspace pane.
636
- let authority = verified_ambient_pane_authority(workspace, &tmux)
637
- .map_err(PrepareLeaderStartError::PaneAuthorityRefused)?;
623
+ if workspace_state_is_managed_provider_reentry(workspace)
624
+ || (provider == Provider::Pi && provider_args.is_empty() && !allow_nested_attach)
625
+ {
626
+ // Only verified current-pane authority permits replacing the caller.
638
627
  return Ok((Some(authority), None, true));
639
628
  }
640
- let target_socket_name = TmuxBackend::for_workspace(workspace)
641
- .tmux_endpoint()
642
- .ok_or_else(|| LeaderError::Start("workspace tmux endpoint missing".to_string()))?;
643
629
  let target_endpoint = crate::tmux_backend::socket_path_for_workspace(workspace);
644
630
  let same_server = target_endpoint.as_ref().is_some_and(|target| {
645
- let observed = Path::new(&observed_endpoint)
631
+ let observed = Path::new(&authority.endpoint)
646
632
  .canonicalize()
647
- .unwrap_or_else(|_| PathBuf::from(&observed_endpoint));
633
+ .unwrap_or_else(|_| PathBuf::from(&authority.endpoint));
648
634
  let target = target.canonicalize().unwrap_or_else(|_| target.clone());
649
635
  observed == target
650
636
  });
651
637
  if same_server {
652
- let authority = verified_ambient_pane_authority(workspace, &tmux)
653
- .map_err(PrepareLeaderStartError::PaneAuthorityRefused)?;
654
638
  return Ok((
655
639
  Some(authority),
656
640
  Some(ManagedClientAttachMode::SwitchClient),
657
641
  false,
658
642
  ));
659
643
  }
660
- if !allow_nested_attach {
661
- return Err(LeaderError::Validation(format!(
662
- "{DIFFERENT_TMUX_SERVER_PREFIX}: observed_endpoint={observed_endpoint}; \
663
- requested_workspace_socket={target_socket_name}"
664
- ))
665
- .into());
644
+ if allow_nested_attach {
645
+ return Ok((
646
+ Some(authority),
647
+ Some(ManagedClientAttachMode::AttachSession),
648
+ false,
649
+ ));
666
650
  }
667
- let authority = verified_ambient_pane_authority(workspace, &tmux)
668
- .map_err(PrepareLeaderStartError::PaneAuthorityRefused)?;
669
- Ok((
670
- Some(authority),
671
- Some(ManagedClientAttachMode::AttachSession),
672
- false,
673
- ))
651
+ // Do not switch/adopt the unrelated server. The existing managed attach
652
+ // child clears TMUX/TMUX_PANE without changing the caller's environment.
653
+ Ok(isolated())
674
654
  }
675
655
 
676
656
  fn validated_ambient_tmux_endpoint(
@@ -4,7 +4,7 @@ use std::time::Duration;
4
4
 
5
5
  use crate::lifecycle::launch::pi_mcp::pi_seat_paths;
6
6
  use crate::messaging::delivery::{
7
- paste_to_submit_floor_for_recipient, recipient_allows_explicit_queue_flush, recipient_is_busy,
7
+ paste_to_submit_floor_for_recipient, recipient_allows_explicit_queue_flush,
8
8
  recipient_requires_single_enter,
9
9
  };
10
10
  use crate::model::enums::{Provider, ProviderEffort};
@@ -186,17 +186,12 @@ fn pi_delivery_submits_one_enter_and_never_flushes_or_retries() {
186
186
  paste_to_submit_floor_for_recipient(&idle, "worker-a"),
187
187
  Duration::ZERO
188
188
  );
189
- assert!(!recipient_is_busy(&idle, "worker-a"));
190
-
191
189
  let busy = serde_json::json!({
192
190
  "agents": {
193
191
  "worker-a": {"provider": "pi", "status": "busy"}
194
192
  }
195
193
  });
196
- assert!(
197
- recipient_is_busy(&busy, "worker-a"),
198
- "known busy must take the existing send.deferred_busy path before paste"
199
- );
194
+ // Working native CLIs queue the same single-Enter input; no idle admission.
200
195
  assert!(recipient_requires_single_enter(&busy, "worker-a"));
201
196
  assert!(!recipient_allows_explicit_queue_flush(&busy, "worker-a"));
202
197
  }
@@ -299,34 +299,6 @@ fn prepare_message_state(
299
299
  }
300
300
  }
301
301
  }
302
- if batch
303
- && recipient_is_busy(
304
- prepared.scoped.as_ref().unwrap_or(state),
305
- &message.recipient,
306
- )
307
- {
308
- event_log.write(
309
- "send.deferred_busy",
310
- serde_json::json!({
311
- "message_id": message_id,
312
- "sender": message.sender,
313
- "recipient": message.recipient,
314
- "reason": "recipient_busy",
315
- }),
316
- )?;
317
- return Ok(Err(DeliveryOutcome {
318
- ok: false,
319
- status: DeliveryStatus::Queued,
320
- message_status: MessageStatusShadow(message.status.clone()),
321
- message_id: Some(message_id.to_string()),
322
- verification: None,
323
- stage: None,
324
- reason: None,
325
- channel: None,
326
- ack_forced_off: false,
327
- turn_verification: None,
328
- }));
329
- }
330
302
  Ok(Ok(prepared))
331
303
  }
332
304
 
@@ -682,34 +654,33 @@ fn deliver_prepared_message(
682
654
  canonical_owner_team_id.as_deref(),
683
655
  resolved.metadata.as_ref(),
684
656
  );
685
- let recipient_cursor = recipient_is_cursor_agent(state, &message.recipient);
686
- let native_request = recipient_provider(state, &message.recipient)
687
- .filter(|provider| matches!(provider, Provider::Pi | Provider::CursorAgent))
688
- .filter(|_| message.recipient != "leader" && transport.supports_owned_native_input())
689
- .map(|provider| crate::transport::native_input::NativeInputRequest {
690
- message_id: message_id.to_string(),
691
- owner_team_id: canonical_owner_team_id.clone().unwrap_or_default(),
692
- recipient: message.recipient.clone(), provider,
693
- pane_id: resolved.metadata.as_ref().map(|meta| meta.target_pane_id.clone()).unwrap_or_default(),
694
- pane_pid: resolved.metadata.as_ref().and_then(|meta| meta.target_pane_pid).unwrap_or_default(),
695
- session: resolved.metadata.as_ref().map(|meta| meta.target_session.clone()).unwrap_or_default(),
696
- window: resolved.metadata.as_ref().map(|meta| meta.target_window.clone()).unwrap_or_default(),
697
- });
698
- let guarded_native = native_request.is_some();
699
- let (inject_attempt, native_evidence) = crate::transport::native_input::with_native_input(native_request, || {
700
- crate::tmux_backend::with_paste_to_submit_floor(
701
- paste_to_submit_floor_for_recipient(state, &message.recipient),
702
- || crate::tmux_backend::with_cursor_single_enter(recipient_cursor, || {
703
- transport.inject_with_submit_observer(&target, &payload, Key::Enter, true, Some(&submit_observer))
704
- }),
705
- )
706
- });
657
+ // v0.5.113 delivery: address/owner and pane serialization are authoritative;
658
+ // composer appearance is not admission. Native CLIs buffer/queue input.
659
+ let inject_attempt = crate::tmux_backend::with_paste_to_submit_floor(
660
+ paste_to_submit_floor_for_recipient(state, &message.recipient),
661
+ || {
662
+ crate::tmux_backend::with_cursor_single_enter(
663
+ recipient_requires_single_enter(state, &message.recipient),
664
+ || {
665
+ transport.inject_with_submit_observer(
666
+ &target,
667
+ &payload,
668
+ Key::Enter,
669
+ true,
670
+ Some(&submit_observer),
671
+ )
672
+ },
673
+ )
674
+ },
675
+ );
707
676
  let inject_report = match inject_attempt {
708
677
  Ok(report) => {
709
678
  // grok 忙时第一次回车只入显式队列。默认 send-now:只重按回车顶出去。
710
679
  // 无 `Enter:send now` 时零次额外按键,claude/codex 行为不变。
711
680
  // cursor:第二下 Enter 打断进行中回合,不走 flush。
712
- if !guarded_native && !crate::provider::submit_now::keep_provider_queue_requested() && !recipient_cursor {
681
+ if !crate::provider::submit_now::keep_provider_queue_requested()
682
+ && recipient_allows_explicit_queue_flush(state, &message.recipient)
683
+ {
713
684
  match crate::provider::submit_now::flush_explicit_queue(transport, &target) {
714
685
  Ok(flush) if flush.extra_enters > 0 => {
715
686
  let _ = event_log.write(
@@ -739,28 +710,6 @@ fn deliver_prepared_message(
739
710
  }
740
711
  Err(error) => {
741
712
  let reason = format!("inject_failed:{error}");
742
- if guarded_native {
743
- let pasted = native_evidence.as_ref().filter(|evidence|
744
- evidence.phase >= crate::transport::native_input::NativeInputPhase::MayHavePasted);
745
- let status = if let Some(evidence) = pasted {
746
- super::enter_rescue::park(store, evidence, attempt, &reason, event_log)?;
747
- "submitted_unverified"
748
- } else {
749
- super::enter_rescue::release_no_effect(store, message_id, attempt)?;
750
- "target_resolved"
751
- };
752
- event_log.write("native_input.deferred", serde_json::json!({
753
- "message_id": message_id, "reason": reason, "status": status,
754
- "may_have_pasted": pasted.is_some(),
755
- }))?;
756
- return Ok(DeliveryOutcome {
757
- ok: false, status: DeliveryStatus::Queued,
758
- message_status: MessageStatusShadow(status.to_string()),
759
- message_id: Some(message_id.to_string()), verification: Some(reason),
760
- stage: Some(DeliveryStage::Inject), reason: None, channel: None,
761
- ack_forced_off: false, turn_verification: None,
762
- });
763
- }
764
713
  if message.recipient == "leader" {
765
714
  store.mark(message_id, "failed", Some("leader_not_attached"))?;
766
715
  event_log.write(
@@ -926,11 +875,7 @@ fn deliver_prepared_message(
926
875
  turn_verification: Some(turn_verification),
927
876
  });
928
877
  }
929
- if let Some(evidence) = native_evidence.as_ref() {
930
- super::enter_rescue::park(store, evidence, attempt, &reason, event_log)?;
931
- } else {
932
- store.mark(message_id, "submitted_unverified", Some(&reason))?;
933
- }
878
+ store.mark(message_id, "submitted_unverified", Some(&reason))?;
934
879
  return Ok(DeliveryOutcome {
935
880
  ok: false,
936
881
  status: DeliveryStatus::Failed,
@@ -2305,8 +2250,8 @@ fn only_team_entry(state: &serde_json::Value) -> Option<&serde_json::Value> {
2305
2250
  }
2306
2251
  }
2307
2252
 
2308
- /// `_deliver_pending_messages` (`delivery.py:484`):扫 pending 队列逐条投递;busy 收件人写
2309
- /// `send.deferred_busy` 跳过 (**不丢**,card §131)。返回投递的 message_id 列表。
2253
+ /// Deliver pending rows without activity/composer admission; native CLIs queue input.
2254
+ /// Returns the message IDs whose post-submit evidence verified delivery.
2310
2255
  pub fn deliver_pending_messages(
2311
2256
  workspace: &Path,
2312
2257
  state: &serde_json::Value,
@@ -2500,13 +2445,6 @@ fn recipient_provider(state: &serde_json::Value, recipient: &str) -> Option<Prov
2500
2445
  .and_then(parse_canonical_provider)
2501
2446
  }
2502
2447
 
2503
- fn recipient_is_cursor_agent(state: &serde_json::Value, recipient: &str) -> bool {
2504
- matches!(
2505
- recipient_provider(state, recipient),
2506
- Some(Provider::CursorAgent)
2507
- )
2508
- }
2509
-
2510
2448
  /// ---
2511
2449
  /// purpose: paste→Enter 地板按收件人 provider 选择
2512
2450
  /// contract: CursorAgent 与 Grok 为 1s;其余(含 claude)为 ZERO
@@ -2549,16 +2487,6 @@ pub(crate) fn recipient_allows_explicit_queue_flush(
2549
2487
  )
2550
2488
  }
2551
2489
 
2552
- pub(crate) fn recipient_is_busy(state: &serde_json::Value, recipient: &str) -> bool {
2553
- state
2554
- .get("agents")
2555
- .and_then(serde_json::Value::as_object)
2556
- .and_then(|agents| agents.get(recipient))
2557
- .and_then(|agent| agent.get("status"))
2558
- .and_then(serde_json::Value::as_str)
2559
- == Some("busy")
2560
- }
2561
-
2562
2490
  fn leader_rollout_path(state: &serde_json::Value) -> Option<std::path::PathBuf> {
2563
2491
  if let Some(receiver) = leader_receiver_value(state).filter(|receiver| {
2564
2492
  receiver.get("provider").and_then(serde_json::Value::as_str) == Some("pi")
@@ -735,7 +735,7 @@ fn resource188_batch_and_public_single_prepare_once_and_keep_fresh_leader_load()
735
735
  }
736
736
 
737
737
  #[test]
738
- fn resource188_busy_and_refused_candidates_prepare_once_without_claiming() {
738
+ fn resource188_busy_reaches_claim_but_owner_refusal_does_not() {
739
739
  use crate::messaging::delivery::preparation_probe::COUNTS;
740
740
  let mut case = Case::new("188-busy");
741
741
  case.state["agents"] = serde_json::json!({"worker": {"agent_id": "worker", "status": "busy"}});
@@ -767,14 +767,15 @@ fn resource188_busy_and_refused_candidates_prepare_once_without_claiming() {
767
767
  Some("missing-team"),
768
768
  )
769
769
  .unwrap();
770
- let before = case.message_status(&busy);
771
770
  COUNTS.with(|counts| counts.set((0, 0, 0)));
772
771
  deliver_pending_messages(&case.workspace, &case.state, &transport, &case.event_log).unwrap();
773
- assert_eq!(COUNTS.with(|counts| counts.get()), (2, 2, 0));
774
- assert_eq!(case.message_status(&busy), before);
772
+ assert_eq!(COUNTS.with(|counts| counts.get()), (2, 2, 1));
773
+ assert_ne!(case.message_status(&busy), "accepted");
775
774
  assert_ne!(case.message_status(&refused), "delivered");
775
+ // This leader-only transport has no worker target. Removing idle admission
776
+ // must not also remove target binding: the claimed row still cannot inject.
776
777
  assert_eq!(transport.inject_count(), 0);
777
- assert!(case.events().contains("send.deferred_busy"));
778
+ assert!(!case.events().contains("send.deferred_busy"));
778
779
  assert!(case.events().contains("delivery.projection_refused"));
779
780
  }
780
781
 
@@ -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
@@ -20,6 +20,12 @@ enum Mode {
20
20
  R6IdleNotice,
21
21
  PiFolded,
22
22
  PiFoldedUnstable,
23
+ CursorFollowup,
24
+ CursorHeaderEvicted,
25
+ CursorHistoryOnly,
26
+ CursorLongHistory,
27
+ CursorExpanded,
28
+ CursorExpandedDirty,
23
29
  }
24
30
  struct State {
25
31
  mode: Mode,
@@ -53,7 +59,13 @@ impl CommandRunner for Runner {
53
59
  };
54
60
  let height = if matches!(
55
61
  state.mode,
56
- Mode::R6IdleNotice | Mode::PiFolded | Mode::PiFoldedUnstable
62
+ Mode::R6IdleNotice
63
+ | Mode::PiFolded
64
+ | Mode::PiFoldedUnstable
65
+ | Mode::CursorHistoryOnly
66
+ | Mode::CursorLongHistory
67
+ | Mode::CursorExpanded
68
+ | Mode::CursorExpandedDirty
57
69
  ) {
58
70
  24
59
71
  } else {
@@ -168,14 +180,58 @@ impl CommandRunner for Runner {
168
180
  let stdout = if state.provider == Provider::CursorAgent
169
181
  && argv.iter().any(|arg| arg == "capture-pane")
170
182
  {
183
+ let followup = matches!(
184
+ state.mode,
185
+ Mode::CursorFollowup
186
+ | Mode::CursorHeaderEvicted
187
+ | Mode::CursorHistoryOnly
188
+ | Mode::CursorLongHistory
189
+ | Mode::CursorExpanded
190
+ | Mode::CursorExpandedDirty
191
+ );
192
+ let placeholder = if followup {
193
+ "Add a follow-up"
194
+ } else {
195
+ "Plan, search, build anything"
196
+ };
197
+ let header = if matches!(state.mode, Mode::CursorFollowup)
198
+ || (matches!(state.mode, Mode::CursorHeaderEvicted) && state.pasted > 0)
199
+ {
200
+ ""
201
+ } else {
202
+ "Cursor Agent\nv2026.10.01-e373342\n"
203
+ };
171
204
  let (composer, history) = if state.entered > 0 {
172
- ("Plan, search, build anything", "[Pasted text #2 +25 lines]")
205
+ (placeholder, "[Pasted text #2 +25 lines]")
173
206
  } else if state.pasted > 0 {
174
207
  ("[Pasted text #2 +25 lines]", "[Pasted text #1 +25 lines]")
175
208
  } else {
176
- ("Plan, search, build anything", "[Pasted text #1 +25 lines]")
209
+ (placeholder, "[Pasted text #1 +25 lines]")
210
+ };
211
+ let padding = if matches!(state.mode, Mode::CursorLongHistory) {
212
+ "historical transcript row\n".repeat(70)
213
+ } else {
214
+ String::new()
177
215
  };
178
- format!("Cursor Agent\nv2026.10.01-e373342\n{history}\n→ {composer}\nGrok 4.7 High Run Everything\n/owned/project\n")
216
+ let frame = format!(
217
+ "{header}{history}\n{padding}→ {composer}\nGrok 4.7 High Run Everything\n/owned/project\n"
218
+ );
219
+ if matches!(state.mode, Mode::CursorExpanded | Mode::CursorExpandedDirty) {
220
+ let expanded = include_str!("../../tests/fixtures/cursor-followup-expanded.txt");
221
+ if matches!(state.mode, Mode::CursorExpandedDirty)
222
+ || state.pasted > 0 && state.entered == 0
223
+ {
224
+ format!("[Pasted text #1 +30 lines]\n{expanded}")
225
+ } else if state.entered > 0 {
226
+ format!("{marker}\n→ Add a follow-up\nGrok 4.7 256K High · 8.6% Run Everything\n/owned/project\n")
227
+ } else {
228
+ "[Pasted text #1 +30 lines]\n→ Add a follow-up\nGrok 4.7 256K High · 8.6% Run Everything\n/owned/project\n".into()
229
+ }
230
+ } else if matches!(state.mode, Mode::CursorHistoryOnly) {
231
+ format!("{frame}{}", "unknown current row\n".repeat(24))
232
+ } else {
233
+ frame
234
+ }
179
235
  } else {
180
236
  stdout
181
237
  };
@@ -703,6 +759,87 @@ fn native_input_cursor_owned_new_capsule_transfers_to_transcript_without_token_c
703
759
  assert_eq!(state.entered, 1);
704
760
  }
705
761
 
762
+ #[test]
763
+ fn native_input_cursor_clean_followup_uses_one_paste_one_enter_across_banner_eviction() {
764
+ for mode in [
765
+ Mode::CursorFollowup,
766
+ Mode::CursorHeaderEvicted,
767
+ Mode::CursorLongHistory,
768
+ ] {
769
+ let f = Fixture::new(mode);
770
+ f.state.lock().unwrap().provider = Provider::CursorAgent;
771
+ let (report, evidence) = f.inject();
772
+ let report = report.unwrap();
773
+ assert_eq!(
774
+ report.submit_verification,
775
+ SubmitVerification::EnterSentWithoutPlaceholderCheck
776
+ );
777
+ let evidence = evidence.unwrap();
778
+ assert_eq!(evidence.phase, NativeInputPhase::Accepted);
779
+ assert_eq!(
780
+ evidence.capsule.as_deref(),
781
+ Some("[Pasted text #2 +25 lines]")
782
+ );
783
+ let state = f.state.lock().unwrap();
784
+ assert_eq!((state.pasted, state.entered), (1, 1));
785
+ }
786
+ }
787
+
788
+ #[test]
789
+ fn native_input_cursor_expanded_followup_reaches_seen_then_one_enter() {
790
+ let f = Fixture::new(Mode::CursorExpanded);
791
+ f.state.lock().unwrap().provider = Provider::CursorAgent;
792
+ let text = "For a harmless terminal-scroll fixture, reply with exactly 240 numbered lines, one per line, from 001 through 240, each formatted `NNN: amber`; do not use tools or change any files. Then wait.\n\n[team-agent-token:msg_one]";
793
+ let (report, evidence) = with_native_input(Some(f.request()), || {
794
+ f.backend.inject(
795
+ &Target::Pane(PaneId::new("%1")),
796
+ &InjectPayload::Text(text.into()),
797
+ Key::Enter,
798
+ true,
799
+ )
800
+ });
801
+ let report = report.unwrap();
802
+ assert_eq!(
803
+ report.submit_verification,
804
+ SubmitVerification::EnterSentWithoutPlaceholderCheck
805
+ );
806
+ assert_eq!(
807
+ report.inject_verification,
808
+ InjectVerification::CaptureContainsToken
809
+ );
810
+ let evidence = evidence.unwrap();
811
+ assert_eq!(evidence.phase, NativeInputPhase::Accepted);
812
+ assert!(evidence.capsule.is_none());
813
+ assert_eq!(evidence.baseline_capsules, ["[Pasted text #1 +30 lines]"]);
814
+ let state = f.state.lock().unwrap();
815
+ assert_eq!((state.pasted, state.entered), (1, 1));
816
+ assert!(state.stage_captures[1] >= 2);
817
+ }
818
+
819
+ #[test]
820
+ fn native_input_cursor_foreign_expanded_followup_is_zero_effect() {
821
+ let f = Fixture::new(Mode::CursorExpandedDirty);
822
+ f.state.lock().unwrap().provider = Provider::CursorAgent;
823
+ let (report, _) = f.inject();
824
+ assert!(report.is_err());
825
+ let state = f.state.lock().unwrap();
826
+ assert_eq!((state.pasted, state.entered), (0, 0));
827
+ }
828
+
829
+ #[test]
830
+ fn native_input_cursor_history_only_composer_cannot_authorize_paste_or_enter() {
831
+ let f = Fixture::new(Mode::CursorHistoryOnly);
832
+ f.state.lock().unwrap().provider = Provider::CursorAgent;
833
+ let (result, _) = f.inject();
834
+ let state = f.state.lock().unwrap();
835
+ assert_eq!(
836
+ (state.pasted, state.entered),
837
+ (0, 0),
838
+ "old history is not current composer authority"
839
+ );
840
+ assert!(result.is_err());
841
+ }
842
+
706
843
  #[test]
707
844
  fn native_input_buffer_uses_authoritative_message_not_first_quoted_marker() {
708
845
  let f = Fixture::new(Mode::Normal);
@@ -202,16 +202,27 @@ pub(crate) fn inspect_input(
202
202
  ) -> Option<InputFrame> {
203
203
  match provider {
204
204
  Provider::Pi => pi_input(text, pane_height),
205
- Provider::CursorAgent => cursor_input(text),
205
+ Provider::CursorAgent => cursor_input(text, pane_height),
206
206
  _ => None,
207
207
  }
208
208
  }
209
209
 
210
- fn cursor_input(text: &str) -> Option<InputFrame> {
210
+ fn cursor_input(text: &str, pane_height: u32) -> Option<InputFrame> {
211
211
  use team_agent_contract::contract::delivery::InputSurface;
212
+ if pane_height == 0 {
213
+ return None;
214
+ }
215
+ let text_rows: Vec<_> = text.lines().collect();
216
+ // Only the current viewport authorizes input; retain full history below
217
+ // solely for same-message transcript correlation.
218
+ let viewport_start = text_rows.len().saturating_sub(pane_height as usize);
219
+ let viewport = text_rows[viewport_start..].join("\n");
212
220
  let (surface, rows, capsule, busy) =
213
- team_agent_contract::providers::cursor::interaction::inspect_composer(text)?;
214
- let first = rows.first()?.strip_prefix("→ ")?;
221
+ team_agent_contract::providers::cursor::interaction::inspect_legacy_composer(&viewport)?;
222
+ let first = match *rows.first()? {
223
+ "→" => "",
224
+ row => row.strip_prefix("→ ")?,
225
+ };
215
226
  let footer = rows.iter().rposition(|row| {
216
227
  row.strip_suffix("Run Everything").is_some_and(|prefix| {
217
228
  !prefix.trim().is_empty() && !prefix.trim_start().starts_with("→ ")
@@ -228,9 +239,13 @@ fn cursor_input(text: &str) -> Option<InputFrame> {
228
239
  if tail.is_empty() {
229
240
  return None;
230
241
  }
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")));
242
+ // Startup history can leave Tail(200) between Ready/Pasted/Accepted. The
243
+ // immutable carrier birth/nonce remains in NativeInputBinding; this UI
244
+ // context is the current native cwd/footer tail, not a version attestation.
245
+ let identity = format!(
246
+ "cursor-current-composer-v1:{}",
247
+ input_digest(&tail.join("\n"))
248
+ );
234
249
  let footer_row = rows[footer]
235
250
  .split_whitespace()
236
251
  .collect::<Vec<_>>()
@@ -247,10 +262,9 @@ fn cursor_input(text: &str) -> Option<InputFrame> {
247
262
  .collect::<Vec<_>>()
248
263
  .join("\n")
249
264
  };
250
- let text_rows: Vec<_> = text.lines().collect();
251
265
  let arrow = text_rows
252
266
  .iter()
253
- .rposition(|line| line.trim().starts_with("→ "))?;
267
+ .rposition(|line| line.trim() == "→" || line.trim().starts_with("→ "))?;
254
268
  Some(InputFrame {
255
269
  composer,
256
270
  transcript: text.lines().take(arrow).collect::<Vec<_>>().join("\n"),
@@ -544,6 +558,114 @@ mod tests {
544
558
  }
545
559
  }
546
560
 
561
+ // Native Issue #328 current-composer rows; transcript removed and private
562
+ // cwd replaced with /owned/project. Startup banner is genuinely absent
563
+ // from the owned pane's Tail(200), not a fabricated invalid version.
564
+ const CURSOR_FOLLOWUP: &str =
565
+ include_str!("../../tests/fixtures/cursor-followup-headerless.txt");
566
+
567
+ #[test]
568
+ fn native_input_cursor_clean_followup_does_not_require_scrolled_startup_banner() {
569
+ let ready = inspect_input(Provider::CursorAgent, CURSOR_FOLLOWUP).unwrap();
570
+ assert!(ready.empty());
571
+ assert!(!ready.busy);
572
+ assert!(ready.capsule.is_none());
573
+ let header = format!("Cursor Agent\nv2026.10.14-0f9edde\n{CURSOR_FOLLOWUP}");
574
+ let with_header = inspect_input(Provider::CursorAgent, &header).unwrap();
575
+ assert!(with_header.empty() && !with_header.busy);
576
+ assert_eq!(ready.footer, with_header.footer);
577
+ assert_eq!(
578
+ ready.identity, with_header.identity,
579
+ "startup history is not a new UI identity"
580
+ );
581
+ }
582
+
583
+ #[test]
584
+ fn native_input_cursor_headerless_followup_retains_draft_busy_and_footer_fences() {
585
+ let label = "[Pasted text #2 +25 lines]";
586
+ let folded = CURSOR_FOLLOWUP.replace("Add a follow-up", label);
587
+ let draft = inspect_input(Provider::CursorAgent, &folded).unwrap();
588
+ assert!(!draft.empty());
589
+ assert_eq!(draft.capsule.as_deref(), Some(label));
590
+ let dirty = CURSOR_FOLLOWUP.replace("Add a follow-up", "Add a follow-up draft");
591
+ assert!(!inspect_input(Provider::CursorAgent, &dirty)
592
+ .unwrap()
593
+ .empty());
594
+ for dirty in [
595
+ CURSOR_FOLLOWUP.replace("Add a follow-up", "Add a follow-up\nforeign draft"),
596
+ CURSOR_FOLLOWUP.replace("Add a follow-up", &format!("{label}\nforeign draft")),
597
+ ] {
598
+ assert!(inspect_input(Provider::CursorAgent, &dirty).is_none());
599
+ }
600
+ let busy = CURSOR_FOLLOWUP.replace("Add a follow-up", "Add a follow-up ctrl+c to stop");
601
+ assert!(inspect_input(Provider::CursorAgent, &busy).unwrap().busy);
602
+ let working = format!("⠘⠆ Working\n{CURSOR_FOLLOWUP}");
603
+ assert!(inspect_input(Provider::CursorAgent, &working).unwrap().busy);
604
+ for unknown in [
605
+ "→ Add a follow-up\n".to_string(),
606
+ CURSOR_FOLLOWUP.replace("Run Everything", "unknown footer"),
607
+ CURSOR_FOLLOWUP.replace(" /owned/project\n", ""),
608
+ ] {
609
+ assert!(inspect_input(Provider::CursorAgent, &unknown).is_none());
610
+ }
611
+ }
612
+
613
+ #[test]
614
+ fn native_input_cursor_real_expanded_followup_is_nonempty_current_marker_not_capsule() {
615
+ let expanded = include_str!("../../tests/fixtures/cursor-followup-expanded.txt");
616
+ let marker = "[team-agent-token:msg_one]";
617
+ for prefix in [
618
+ "".to_string(),
619
+ format!("Cursor Agent\nv2026.10.14-0f9edde\n{CURSOR_FOLLOWUP}"),
620
+ ] {
621
+ let text = format!("{prefix}{expanded}");
622
+ let frame = inspect_input(Provider::CursorAgent, &text).unwrap();
623
+ assert!(!frame.empty() && !frame.busy);
624
+ assert!(frame.has_marker(marker));
625
+ assert!(frame.capsule.is_none());
626
+ assert!(!frame.transcript.contains(marker));
627
+ let changed = text.replace("/owned/project", "/different/project");
628
+ assert_ne!(
629
+ frame.identity,
630
+ inspect_input(Provider::CursorAgent, &changed)
631
+ .unwrap()
632
+ .identity
633
+ );
634
+ }
635
+ let duplicate = expanded.replace(marker, &format!("{marker}\n{marker}"));
636
+ assert!(!inspect_input(Provider::CursorAgent, &duplicate)
637
+ .unwrap()
638
+ .has_marker(marker));
639
+ let busy = expanded.replace(" →", "⠘⠆ Working\n →");
640
+ assert!(inspect_input(Provider::CursorAgent, &busy).unwrap().busy);
641
+ for unknown in [
642
+ "→\nGrok 4.7 High Run Everything\n/owned/project\n".to_string(),
643
+ "→\nforeign draft\n→ Add a follow-up\nGrok 4.7 High Run Everything\n/owned/project\n"
644
+ .to_string(),
645
+ expanded.replace("Run Everything", "unknown footer"),
646
+ expanded.replace(" /owned/project\n", ""),
647
+ format!("{expanded}{}", "unknown current row\n".repeat(24)),
648
+ ] {
649
+ assert!(super::inspect_input(Provider::CursorAgent, &unknown, 24).is_none());
650
+ }
651
+ }
652
+
653
+ #[test]
654
+ fn native_input_cursor_requires_composer_in_current_viewport_not_old_history() {
655
+ for header in ["", "Cursor Agent\nv2026.10.14-0f9edde\n"] {
656
+ let hidden = format!(
657
+ "{header}{CURSOR_FOLLOWUP}{}",
658
+ "unknown current row\n".repeat(24)
659
+ );
660
+ assert!(super::inspect_input(Provider::CursorAgent, &hidden, 24).is_none());
661
+ }
662
+ assert!(super::inspect_input(Provider::CursorAgent, CURSOR_FOLLOWUP, 0).is_none());
663
+ let current = super::inspect_input(Provider::CursorAgent, CURSOR_FOLLOWUP, 24).unwrap();
664
+ let changed = CURSOR_FOLLOWUP.replace("/owned/project", "/different/project");
665
+ let changed = super::inspect_input(Provider::CursorAgent, &changed, 24).unwrap();
666
+ assert_ne!(current.identity, changed.identity);
667
+ }
668
+
547
669
  #[test]
548
670
  fn native_input_cursor_separates_folded_draft_from_history_and_busy() {
549
671
  let capsule = "[Pasted text #1 +25 lines]";
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.118",
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.118",
24
+ "@team-agent/cli-darwin-x64": "0.5.118",
25
+ "@team-agent/cli-linux-x64": "0.5.118"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",