@team-agent/installer 0.5.9 → 0.5.10

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
@@ -575,7 +575,7 @@ dependencies = [
575
575
 
576
576
  [[package]]
577
577
  name = "team-agent"
578
- version = "0.5.9"
578
+ version = "0.5.10"
579
579
  dependencies = [
580
580
  "anyhow",
581
581
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent", "crates/win-conpty-phase0", "crates/conpty-trans
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.5.9"
12
+ version = "0.5.10"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -267,6 +267,26 @@ pub(crate) fn resolve_name_for_cli(
267
267
  Ok((resolved, transport))
268
268
  }
269
269
 
270
+ /// E6 wiring helper (`cli::send::maybe_enqueue_offline_leader_mailbox`):
271
+ /// re-parse a `<workspace>::<team>/leader` name and return the resolved
272
+ /// target workspace + canonical team_key so send.rs can enqueue the
273
+ /// mailbox without duplicating the parser. Returns `Ok(None)` for names
274
+ /// that aren't `<team>/leader` shapes (worker sends, session:window,
275
+ /// etc. keep their existing refusal semantics).
276
+ pub(crate) fn parse_leader_target_workspace_and_team(
277
+ sender_workspace: &Path,
278
+ raw_name: &str,
279
+ ) -> Result<Option<(PathBuf, String)>, NamedAddressError> {
280
+ let parsed = parse_named_address(raw_name)?;
281
+ let target_workspace = resolve_workspace(sender_workspace, parsed.workspace.as_deref())?;
282
+ match parsed.target {
283
+ ParsedTarget::TeamEntity { team, entity } if entity == "leader" => {
284
+ Ok(Some((target_workspace, team)))
285
+ }
286
+ _ => Ok(None),
287
+ }
288
+ }
289
+
270
290
  #[derive(Debug, Clone, PartialEq, Eq)]
271
291
  struct ParsedNamedAddress {
272
292
  workspace: Option<PathBuf>,
@@ -52,10 +52,34 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
52
52
  let (resolved, transport) =
53
53
  match crate::cli::named_address::resolve_name_for_cli(&args.workspace, to_name) {
54
54
  Ok(resolved) => resolved,
55
- Err(error) if args.json => {
56
- return Ok(CmdResult::from_json(error.to_json(), args.json));
55
+ Err(error) => {
56
+ // E6 (0.5.9 offline-mailbox-toname-design §3.1/§6.2): when
57
+ // the resolver refuses with `leader_not_attached`, the team
58
+ // itself may still be alive (worker + coordinator running
59
+ // without a bound leader). Third-party senders in that
60
+ // shape must land in the offline mailbox — same
61
+ // canonical team.db row + queued_until_leader_attach
62
+ // status the coordinator/attach hook replay through the
63
+ // existing pipeline exactly once. Owner-scope refusals
64
+ // (same workspace as target) keep the actionable attach
65
+ // hint — E6 owner copy is documented as
66
+ // `run team-agent attach-leader`.
67
+ if let Some(mut value) = maybe_enqueue_offline_leader_mailbox(
68
+ &args.workspace,
69
+ to_name,
70
+ &content,
71
+ &args.sender,
72
+ args.task.as_deref(),
73
+ &error,
74
+ )? {
75
+ add_send_reminder_if_ok(&mut value);
76
+ return Ok(cmd_send_result(value, args.json));
77
+ }
78
+ if args.json {
79
+ return Ok(CmdResult::from_json(error.to_json(), args.json));
80
+ }
81
+ return Err(CliError::Usage(error.n38_message()));
57
82
  }
58
- Err(error) => return Err(CliError::Usage(error.n38_message())),
59
83
  };
60
84
  let mut value = send_to_named_pane_direct(
61
85
  &args.workspace,
@@ -786,6 +810,119 @@ fn add_send_reminder_if_ok(value: &mut Value) {
786
810
  }
787
811
  }
788
812
 
813
+ /// E6 (0.5.9 offline-mailbox-toname-design §§3.1/6.2/8, real-machine
814
+ /// escape evidence
815
+ /// `.team/artifacts/0.5.9-subscription-gate.md` +
816
+ /// `.team/evidence/0.5.9-subscription-gate-20260707T143241Z-4645/`):
817
+ /// when the `--to-name <ws>::<team>/leader` resolver refused with
818
+ /// `leader_not_attached`, decide whether the target team is still alive
819
+ /// (worker + coordinator running without a bound leader) and, if so,
820
+ /// enqueue the mailbox row so `attach-leader` replays it exactly once.
821
+ ///
822
+ /// Only queues for third-party senders (sender workspace ≠ target
823
+ /// workspace). Owner-scope refusals stay refused so status/diagnose can
824
+ /// keep pushing the operator toward `attach-leader`.
825
+ fn maybe_enqueue_offline_leader_mailbox(
826
+ sender_workspace: &Path,
827
+ to_name: &str,
828
+ content: &str,
829
+ sender: &str,
830
+ task_id: Option<&str>,
831
+ error: &crate::cli::named_address::NamedAddressError,
832
+ ) -> Result<Option<Value>, CliError> {
833
+ if error.kind != crate::cli::named_address::NamedAddressErrorKind::LeaderNotAttached {
834
+ return Ok(None);
835
+ }
836
+ let parsed = match crate::cli::named_address::parse_leader_target_workspace_and_team(
837
+ sender_workspace,
838
+ to_name,
839
+ ) {
840
+ Ok(Some(v)) => v,
841
+ Ok(None) => return Ok(None),
842
+ Err(_) => return Ok(None),
843
+ };
844
+ let (target_workspace, team_key) = parsed;
845
+ // Owner-scope refusal: sender workspace == target workspace. Keep
846
+ // the actionable attach hint (owner sees status/diagnose copy that
847
+ // points at `attach-leader`).
848
+ let sender_canonical = std::fs::canonicalize(sender_workspace)
849
+ .unwrap_or_else(|_| sender_workspace.to_path_buf());
850
+ let target_canonical = std::fs::canonicalize(&target_workspace)
851
+ .unwrap_or_else(|_| target_workspace.clone());
852
+ if sender_canonical == target_canonical {
853
+ return Ok(None);
854
+ }
855
+ // Verify the target team is actually alive on this host — mailbox
856
+ // is only for `team live + leader unattached`. Fail-closed otherwise
857
+ // so we never leave a message in a permanently-dead workspace's DB.
858
+ let state = match crate::state::persist::load_runtime_state(&target_workspace) {
859
+ Ok(s) => s,
860
+ Err(_) => return Ok(None),
861
+ };
862
+ let team_alive = target_team_is_alive_for_mailbox(&state, &team_key);
863
+ if !team_alive {
864
+ return Ok(None);
865
+ }
866
+ let event_log = crate::event_log::EventLog::new(&target_workspace);
867
+ let task = task_id.map(|s| crate::model::ids::TaskId::new(s.to_string()));
868
+ let outcome = messaging::enqueue_leader_mailbox_until_attach(
869
+ &target_workspace,
870
+ &team_key,
871
+ content,
872
+ task.as_ref(),
873
+ sender,
874
+ &event_log,
875
+ )
876
+ .map_err(|e| CliError::Runtime(e.to_string()))?;
877
+ let message_id = outcome
878
+ .message_id
879
+ .clone()
880
+ .unwrap_or_else(|| "".to_string());
881
+ Ok(Some(json!({
882
+ "ok": true,
883
+ "status": "queued_until_leader_attach",
884
+ "message_status": "queued_until_leader_attach",
885
+ "channel": "leader_mailbox",
886
+ "delivered": false,
887
+ "to_name": to_name,
888
+ "target_workspace": target_workspace.display().to_string(),
889
+ "team_key": team_key,
890
+ "recipient": "leader",
891
+ "leader_attached": false,
892
+ "message_id": message_id,
893
+ })))
894
+ }
895
+
896
+ /// Positive-source liveness heuristic per offline-mailbox-toname-design.md §4:
897
+ /// - target workspace has state and the team key is present + not archived/down;
898
+ /// - AND at least one live tmux fact — a persisted `session_name` OR any
899
+ /// agent with a recorded pane on the recorded socket.
900
+ ///
901
+ /// We deliberately do NOT poll coordinator health here — enqueuing is
902
+ /// safe even when the coordinator is transiently down; attach-leader
903
+ /// itself replays via `requeue_blocked_leader_messages` regardless.
904
+ fn target_team_is_alive_for_mailbox(state: &Value, team_key: &str) -> bool {
905
+ let team = state
906
+ .get("teams")
907
+ .and_then(|v| v.as_object())
908
+ .and_then(|teams| teams.get(team_key));
909
+ let Some(team) = team else {
910
+ return false;
911
+ };
912
+ let status = team
913
+ .get("status")
914
+ .and_then(|v| v.as_str())
915
+ .unwrap_or("alive");
916
+ if matches!(status, "archived" | "down" | "stopped") {
917
+ return false;
918
+ }
919
+ // A recorded session_name is enough — target's coordinator/attach
920
+ // path will re-verify tmux presence when the replay fires.
921
+ team.get("session_name")
922
+ .and_then(|v| v.as_str())
923
+ .is_some_and(|s| !s.is_empty())
924
+ }
925
+
789
926
  fn cmd_send_result(value: Value, as_json: bool) -> CmdResult {
790
927
  let exit = if value.get("ok").and_then(Value::as_bool) == Some(false) {
791
928
  ExitCode::Error
@@ -50,7 +50,7 @@ pub fn attach_leader(
50
50
  if let Some(endpoint) = target.endpoint.as_ref() {
51
51
  receiver.tmux_socket = Some(endpoint.clone());
52
52
  }
53
- let validation = validate_attach_target(workspace, &state, &target.info);
53
+ let validation = validate_attach_target(workspace, &state, &target.info, provider);
54
54
  if validation.is_err() {
55
55
  let pane_info = pane_info_value(&target.info);
56
56
  let targets_value = Value::Array(targets.iter().map(|target| pane_info_value(&target.info)).collect());
@@ -66,6 +66,7 @@ pub fn attach_leader(
66
66
  LeaseSource::Manual,
67
67
  &event_log,
68
68
  )? {
69
+ if let Some(endpoint) = target.endpoint.as_deref() { quiet_fake_leader_pane_echo(provider, &target.info, endpoint); }
69
70
  let _ = requeue_exhausted_watchers_after_attach(workspace, &state, &event_log, &pane_id)?;
70
71
  return Ok(LeaseResult {
71
72
  ok: true,
@@ -98,6 +99,7 @@ pub fn attach_leader(
98
99
  super::LeaderEvent::ReceiverAttached.name(),
99
100
  json!({"pane_id": pane_id.as_str(), "owner_epoch": epoch.0}),
100
101
  )?;
102
+ if let Some(endpoint) = target.endpoint.as_deref() { quiet_fake_leader_pane_echo(provider, &target.info, endpoint); }
101
103
  let _ = requeue_exhausted_watchers_after_attach(workspace, &state, &event_log, &pane_id)?;
102
104
  return Ok(LeaseResult {
103
105
  ok: true,
@@ -121,6 +123,7 @@ pub fn attach_leader(
121
123
  super::LeaderEvent::ReceiverAttached.name(),
122
124
  json!({"pane_id": pane_id.as_str(), "owner_epoch": next_epoch.0}),
123
125
  )?;
126
+ if let Some(endpoint) = target.endpoint.as_deref() { quiet_fake_leader_pane_echo(provider, &target.info, endpoint); }
124
127
  let _ = requeue_exhausted_watchers_after_attach(workspace, &state, &event_log, &pane_id)?;
125
128
  Ok(LeaseResult {
126
129
  ok: true,
@@ -134,6 +137,63 @@ pub fn attach_leader(
134
137
  })
135
138
  }
136
139
 
140
+ /// 0.5.9 (E6 real-machine e2e wiring): Fake-provider leader panes in the
141
+ /// e2e harness run `/bin/cat`, which lets the TTY driver echo every
142
+ /// injected byte AND then prints the same bytes on stdout — so pane
143
+ /// capture ends up with two copies of every delivered token. Real Codex/
144
+ /// Claude/Copilot binaries drive the pane through their own TUI and never
145
+ /// echo raw input, so they don't need this. Attach-leader is the
146
+ /// canonical binding hook for this pane, so it's the right place to
147
+ /// disable the TTY echo bit once. Best-effort: any failure is silent
148
+ /// (Fake-provider binding stays useful even without echo suppression).
149
+ ///
150
+ /// N16/CP-1 (`.team/anchors/REQUIREMENTS.md`): all tmux invocations MUST
151
+ /// go through the `TmuxBackend` single entry point. The pane's tty query
152
+ /// is done via `TmuxBackend::for_tmux_endpoint(endpoint).query(...,
153
+ /// PaneField::PaneTty)` — socket-scoped by construction. The `stty` call
154
+ /// itself is not a tmux operation, so it uses `Command` directly.
155
+ fn quiet_fake_leader_pane_echo(provider: Provider, target: &PaneInfo, endpoint: &str) {
156
+ use crate::transport::{PaneField, Target as TransportTarget};
157
+ if !matches!(provider, Provider::Fake) {
158
+ return;
159
+ }
160
+ if endpoint.is_empty() {
161
+ return;
162
+ }
163
+ let backend = crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint);
164
+ let Ok(Some(tty)) = <crate::tmux_backend::TmuxBackend as crate::transport::Transport>::query(
165
+ &backend,
166
+ &TransportTarget::Pane(target.pane_id.clone()),
167
+ PaneField::PaneTty,
168
+ ) else {
169
+ return;
170
+ };
171
+ if tty.trim().is_empty() {
172
+ return;
173
+ }
174
+ // stty against the pane's tty flips the terminal driver's echo bit
175
+ // without asking the pane process (`/bin/cat`) to interpret any
176
+ // command. Best-effort: failure is silent.
177
+ //
178
+ // Cross-platform tty-file flag: macOS/BSD uses `-f <file>`; GNU
179
+ // coreutils on Linux uses `-F <file>`. CI runs on Linux and the
180
+ // previous BSD-only invocation silently no-op'd there — the token
181
+ // was double-injected because echo stayed on. Try `-F` first (Linux
182
+ // is the CI baseline), then fall back to `-f` on macOS. Both are
183
+ // safe to run: the wrong-platform variant just fails silently.
184
+ let tty = tty.trim();
185
+ let linux_ok = std::process::Command::new("stty")
186
+ .args(["-F", tty, "-echo"])
187
+ .output()
188
+ .map(|o| o.status.success())
189
+ .unwrap_or(false);
190
+ if !linux_ok {
191
+ let _ = std::process::Command::new("stty")
192
+ .args(["-f", tty, "-echo"])
193
+ .output();
194
+ }
195
+ }
196
+
137
197
  /// Explicit app-server leader binding. Validates the supplied socket/thread tuple
138
198
  /// before writing the typed physical-channel anchor through the lease primitive.
139
199
  pub fn attach_app_server_leader(
@@ -824,9 +884,26 @@ fn validate_attach_target(
824
884
  workspace: &Path,
825
885
  state: &Value,
826
886
  target: &PaneInfo,
887
+ requested_provider: Provider,
827
888
  ) -> Result<(), &'static str> {
828
- let Some(claim_target) = claim_target_from_pane_info(workspace, target) else {
829
- return Err("leader_pane_validation_failed");
889
+ // 0.5.9 (E6 real-machine e2e wiring): an explicit `--provider fake`
890
+ // is the operator's declaration that this pane is a fake-provider
891
+ // stub for tests / fixtures. The normal pane-command attribution
892
+ // path can't recognize `/bin/cat` (or any bare shell process) as a
893
+ // provider, so honoring the explicit request here is the only way
894
+ // real-machine E6 acceptance can spin up a leader without shipping
895
+ // a real Codex/Claude/Copilot binary. This is a targeted escape
896
+ // hatch — `Provider::Fake` is not selectable from the user-facing
897
+ // provider list, only wired in test/fixture flows.
898
+ let claim_target = match claim_target_from_pane_info(workspace, target) {
899
+ Some(target) => Some(target),
900
+ None if matches!(requested_provider, Provider::Fake) => None,
901
+ None => return Err("leader_pane_validation_failed"),
902
+ };
903
+ let Some(claim_target) = claim_target else {
904
+ // Fake provider: skip session-uuid check since attribution was
905
+ // bypassed. Nothing else to validate.
906
+ return Ok(());
830
907
  };
831
908
  let recorded_uuid = get_path_str(state, &["team_owner", "leader_session_uuid"])
832
909
  .or_else(|| get_path_str(state, &["leader_receiver", "leader_session_uuid"]));
@@ -108,6 +108,7 @@
108
108
  PaneField::PaneCurrentCommand => "sh",
109
109
  PaneField::PaneCurrentPath => "/tmp/ws",
110
110
  PaneField::SessionName => "team-sess",
111
+ PaneField::PaneTty => "/dev/ttys000",
111
112
  };
112
113
  Ok(Some(value.to_string()))
113
114
  }
@@ -201,6 +201,11 @@ pub enum PaneField {
201
201
  PaneCurrentCommand,
202
202
  PaneCurrentPath,
203
203
  SessionName,
204
+ /// pane's controlling tty (e.g. `/dev/ttys015`). Consumers use it to
205
+ /// flip terminal driver bits like `stty -f <tty> -echo` from outside
206
+ /// tmux's control channel — the query itself still goes through the
207
+ /// backend so N16/CP-1 (single tmux entry point) stays intact.
208
+ PaneTty,
204
209
  }
205
210
 
206
211
  /// 后端种类(诊断/事件用)。
@@ -843,6 +848,7 @@ pub fn tmux_query_argv(pane: &PaneId, field: PaneField) -> Vec<String> {
843
848
  PaneField::PaneCurrentCommand => "#{pane_current_command}",
844
849
  PaneField::PaneCurrentPath => "#{pane_current_path}",
845
850
  PaneField::SessionName => "#{session_name}",
851
+ PaneField::PaneTty => "#{pane_tty}",
846
852
  };
847
853
  let mut argv = vec![
848
854
  "tmux".to_string(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.5.9",
3
+ "version": "0.5.10",
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.9",
24
- "@team-agent/cli-darwin-x64": "0.5.9",
25
- "@team-agent/cli-linux-x64": "0.5.9"
23
+ "@team-agent/cli-darwin-arm64": "0.5.10",
24
+ "@team-agent/cli-darwin-x64": "0.5.10",
25
+ "@team-agent/cli-linux-x64": "0.5.10"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",