@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 +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/named_address.rs +20 -0
- package/crates/team-agent/src/cli/send.rs +140 -3
- package/crates/team-agent/src/leader/lease.rs +80 -3
- package/crates/team-agent/src/transport/tests/mod.rs +1 -0
- package/crates/team-agent/src/transport.rs +6 -0
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -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)
|
|
56
|
-
|
|
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
|
-
|
|
829
|
-
|
|
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"]));
|
|
@@ -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.
|
|
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.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.5.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.5.
|
|
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",
|