@team-agent/installer 0.5.121 → 0.5.122
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/coordinator/tests/health_sync.rs +27 -8
- package/crates/team-agent/src/lifecycle/restart/agent.rs +3 -0
- package/crates/team-agent/src/lifecycle/restart/common.rs +3 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +5 -0
- package/crates/team-agent/src/lifecycle/tests/acceptance_b_batch_red.rs +16 -3
- package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +132 -1
- package/crates/team-agent/src/lifecycle/tests/subprep_codex_trust_roundtrip_red.rs +8 -2
- package/crates/team-agent/src/mcp_server/readiness.rs +66 -3
- package/crates/team-agent/src/mcp_server/readiness_tests.rs +30 -0
- package/crates/team-agent/src/messaging/delivery_consumption_tests.rs +12 -1
- package/crates/team-agent/src/messaging/tests/dup_inject.rs +48 -4
- package/crates/team-agent/src/messaging/tests/spine.rs +45 -14
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -15,6 +15,7 @@ use super::*;
|
|
|
15
15
|
/// agent. `has_session`→true (gate passes); `inject`→Ok (delivery may run); the rest are Ok defaults.
|
|
16
16
|
struct CapturingTransport {
|
|
17
17
|
scrollback: String,
|
|
18
|
+
targets: Vec<PaneInfo>,
|
|
18
19
|
}
|
|
19
20
|
impl Transport for CapturingTransport {
|
|
20
21
|
fn kind(&self) -> BackendKind {
|
|
@@ -73,7 +74,7 @@ impl Transport for CapturingTransport {
|
|
|
73
74
|
Ok(PaneLiveness::Live)
|
|
74
75
|
}
|
|
75
76
|
fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
|
|
76
|
-
Ok(
|
|
77
|
+
Ok(self.targets.clone())
|
|
77
78
|
}
|
|
78
79
|
fn has_session(&self, _s: &SessionName) -> Result<bool, TransportError> {
|
|
79
80
|
Ok(true)
|
|
@@ -106,6 +107,14 @@ impl Transport for CapturingTransport {
|
|
|
106
107
|
fn seeded_health_coord(
|
|
107
108
|
agents: serde_json::Value,
|
|
108
109
|
scrollback: &str,
|
|
110
|
+
) -> (Coordinator, std::path::PathBuf) {
|
|
111
|
+
seeded_health_coord_with_readiness(agents, scrollback, false)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
fn seeded_health_coord_with_readiness(
|
|
115
|
+
agents: serde_json::Value,
|
|
116
|
+
scrollback: &str,
|
|
117
|
+
ready: bool,
|
|
109
118
|
) -> (Coordinator, std::path::PathBuf) {
|
|
110
119
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
111
120
|
static N: AtomicU64 = AtomicU64::new(0);
|
|
@@ -115,11 +124,18 @@ fn seeded_health_coord(
|
|
|
115
124
|
N.fetch_add(1, Ordering::Relaxed)
|
|
116
125
|
));
|
|
117
126
|
std::fs::create_dir_all(&dir).unwrap();
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
127
|
+
let mut state = serde_json::json!({ "session_name": "team-health", "agents": agents });
|
|
128
|
+
#[cfg(unix)]
|
|
129
|
+
let targets = if ready {
|
|
130
|
+
vec![crate::mcp_server::readiness::test_ready_worker(
|
|
131
|
+
&dir, &mut state, "current", "w1", "%1",
|
|
132
|
+
)]
|
|
133
|
+
} else {
|
|
134
|
+
Vec::new()
|
|
135
|
+
};
|
|
136
|
+
#[cfg(not(unix))]
|
|
137
|
+
let targets = { let _ = ready; Vec::new() };
|
|
138
|
+
crate::state::persist::save_runtime_state(&dir, &state).unwrap();
|
|
123
139
|
let ws = WorkspacePath::new(dir.clone());
|
|
124
140
|
let reg: Box<dyn ProviderRegistry> = Box::new(MockRegistry::new(&[], &[]));
|
|
125
141
|
let coord = Coordinator::for_test(
|
|
@@ -127,6 +143,7 @@ fn seeded_health_coord(
|
|
|
127
143
|
reg,
|
|
128
144
|
Box::new(CapturingTransport {
|
|
129
145
|
scrollback: scrollback.to_string(),
|
|
146
|
+
targets,
|
|
130
147
|
}),
|
|
131
148
|
None,
|
|
132
149
|
None,
|
|
@@ -260,10 +277,12 @@ fn spine2_capture_missing_captures_session_id_for_missing_agent() {
|
|
|
260
277
|
// activity/agent_health WORKING remains diagnostic turn state.
|
|
261
278
|
#[test]
|
|
262
279
|
fn spine2_sync_health_working_status_delivers_same_tick() {
|
|
263
|
-
let (coord, dir) =
|
|
280
|
+
let (coord, dir) = seeded_health_coord_with_readiness(
|
|
281
|
+
one_agent("codex"), "Working (5s · esc to interrupt)", true,
|
|
282
|
+
);
|
|
264
283
|
let store = MessageStore::open(&dir).unwrap();
|
|
265
284
|
let mid = store
|
|
266
|
-
.create_message(Some("t"), "leader", "w1", "hi", None, true,
|
|
285
|
+
.create_message(Some("t"), "leader", "w1", "hi", None, true, Some("current"))
|
|
267
286
|
.unwrap();
|
|
268
287
|
let conn = crate::db::schema::open_db(store.db_path()).unwrap();
|
|
269
288
|
conn.execute(
|
|
@@ -1410,6 +1410,9 @@ pub(super) fn mark_agent_started(
|
|
|
1410
1410
|
"spawn_cwd".to_string(),
|
|
1411
1411
|
serde_json::json!(spawn.spawn_cwd.to_string_lossy().to_string()),
|
|
1412
1412
|
);
|
|
1413
|
+
if let Some(path) = &spawn.mcp_config_path {
|
|
1414
|
+
agent.insert("mcp_config".to_string(), serde_json::json!(path));
|
|
1415
|
+
}
|
|
1413
1416
|
// 0.4.6 Stage 1+2: ensure spawn_epoch is present after every start.
|
|
1414
1417
|
// reset_agent_at_paths bumps it before start; non-reset starts
|
|
1415
1418
|
// initialise to 1 (or preserve existing) so capture/event paths can
|
|
@@ -45,6 +45,8 @@ pub(super) struct SpawnedAgentWindow {
|
|
|
45
45
|
pub profile_launch: crate::provider::ProviderProfileLaunch,
|
|
46
46
|
pub layout_placement: Option<crate::lifecycle::launch::LayoutPlacement>,
|
|
47
47
|
pub spawn_cwd: std::path::PathBuf,
|
|
48
|
+
/// Actual configuration generated for this spawn, not inferred from old state.
|
|
49
|
+
pub mcp_config_path: Option<std::path::PathBuf>,
|
|
48
50
|
/// Issue 2 (Round 3b gate review §6): the resolved `owner_team_id` used
|
|
49
51
|
/// for this spawn's MCP env / command. Callers (`mark_agent_respawned`,
|
|
50
52
|
/// `mark_agent_started`) must persist this back into the agent row so
|
|
@@ -701,6 +703,7 @@ pub(super) fn spawn_agent_window(
|
|
|
701
703
|
profile_launch,
|
|
702
704
|
layout_placement: layout_placement.cloned(),
|
|
703
705
|
spawn_cwd: native_spawn_cwd,
|
|
706
|
+
mcp_config_path,
|
|
704
707
|
owner_team_id: team_id,
|
|
705
708
|
})
|
|
706
709
|
}
|
|
@@ -2480,6 +2480,9 @@ fn mark_agent_respawned(
|
|
|
2480
2480
|
"spawn_cwd".to_string(),
|
|
2481
2481
|
serde_json::json!(spawn.spawn_cwd.to_string_lossy().to_string()),
|
|
2482
2482
|
);
|
|
2483
|
+
if let Some(path) = &spawn.mcp_config_path {
|
|
2484
|
+
agent.insert("mcp_config".to_string(), serde_json::json!(path));
|
|
2485
|
+
}
|
|
2483
2486
|
// 0.5.38 (`.team/artifacts/startup-latency-locate.md` §7): every
|
|
2484
2487
|
// multi-worker restart respawn is a fresh process cohort. The
|
|
2485
2488
|
// pre-0.5.38 code only wrote `spawned_at` here and left `spawn_epoch`
|
|
@@ -2968,6 +2971,7 @@ impl Clone for ParallelSpawnResult {
|
|
|
2968
2971
|
profile_launch: self.spawn.profile_launch.clone(),
|
|
2969
2972
|
layout_placement: self.spawn.layout_placement.clone(),
|
|
2970
2973
|
spawn_cwd: self.spawn.spawn_cwd.clone(),
|
|
2974
|
+
mcp_config_path: self.spawn.mcp_config_path.clone(),
|
|
2971
2975
|
owner_team_id: self.spawn.owner_team_id.clone(),
|
|
2972
2976
|
},
|
|
2973
2977
|
spawn_start: self.spawn_start,
|
|
@@ -4232,6 +4236,7 @@ tasks:
|
|
|
4232
4236
|
profile_launch: crate::provider::ProviderProfileLaunch::default(),
|
|
4233
4237
|
layout_placement: None,
|
|
4234
4238
|
spawn_cwd: std::path::PathBuf::from("/tmp/team-epoch"),
|
|
4239
|
+
mcp_config_path: None,
|
|
4235
4240
|
owner_team_id: None,
|
|
4236
4241
|
};
|
|
4237
4242
|
mark_agent_respawned(
|
|
@@ -59,6 +59,14 @@ fn b4_monitor_step_failure_does_not_block_deliver_pending() {
|
|
|
59
59
|
let _g = EnvGuard::set(&[(ANCESTRY_KEY, NEUTRAL_ANCESTRY)]);
|
|
60
60
|
let ws = tmp_ws("b4-monitor");
|
|
61
61
|
seed_copilot_team_state(&ws, "cp1", true);
|
|
62
|
+
let mut state = team_agent::state::persist::load_runtime_state(&ws).unwrap();
|
|
63
|
+
#[cfg(unix)]
|
|
64
|
+
let targets = vec![crate::mcp_server::readiness::test_ready_worker(
|
|
65
|
+
&ws, &mut state, "team-b4", "cp1", "%41",
|
|
66
|
+
)];
|
|
67
|
+
#[cfg(not(unix))]
|
|
68
|
+
let targets = Vec::new();
|
|
69
|
+
save_runtime_state(&ws, &state).unwrap();
|
|
62
70
|
let store = MessageStore::open(&ws).unwrap();
|
|
63
71
|
store
|
|
64
72
|
.create_message(
|
|
@@ -68,14 +76,17 @@ fn b4_monitor_step_failure_does_not_block_deliver_pending() {
|
|
|
68
76
|
"do the thing",
|
|
69
77
|
None,
|
|
70
78
|
false,
|
|
71
|
-
|
|
79
|
+
Some("team-b4"),
|
|
72
80
|
)
|
|
73
81
|
.unwrap();
|
|
74
82
|
|
|
75
83
|
let coord = Coordinator::new(
|
|
76
84
|
WorkspacePath::new(ws.clone()),
|
|
77
85
|
Box::new(RealRegistry),
|
|
78
|
-
Box::new(MonitorFailTransport
|
|
86
|
+
Box::new(MonitorFailTransport {
|
|
87
|
+
targets,
|
|
88
|
+
..MonitorFailTransport::new()
|
|
89
|
+
}),
|
|
79
90
|
);
|
|
80
91
|
let report = coord.tick();
|
|
81
92
|
|
|
@@ -603,11 +614,13 @@ fn attach_socket_path(command: &str) -> Option<PathBuf> {
|
|
|
603
614
|
/// (B-4). Sessions/windows present so the tick proceeds to the monitor steps.
|
|
604
615
|
struct MonitorFailTransport {
|
|
605
616
|
session: Mutex<bool>,
|
|
617
|
+
targets: Vec<PaneInfo>,
|
|
606
618
|
}
|
|
607
619
|
impl MonitorFailTransport {
|
|
608
620
|
fn new() -> Self {
|
|
609
621
|
Self {
|
|
610
622
|
session: Mutex::new(true),
|
|
623
|
+
targets: Vec::new(),
|
|
611
624
|
}
|
|
612
625
|
}
|
|
613
626
|
}
|
|
@@ -688,7 +701,7 @@ impl Transport for MonitorFailTransport {
|
|
|
688
701
|
}
|
|
689
702
|
}
|
|
690
703
|
fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
|
|
691
|
-
Ok(
|
|
704
|
+
Ok(self.targets.clone())
|
|
692
705
|
}
|
|
693
706
|
fn list_windows(&self, _s: &SessionName) -> Result<Vec<WindowName>, TransportError> {
|
|
694
707
|
Ok(vec![WindowName::new("cp1")])
|
|
@@ -36,6 +36,112 @@ use team_agent::lifecycle::{
|
|
|
36
36
|
};
|
|
37
37
|
use team_agent::transport::test_support::OfflineTransport;
|
|
38
38
|
|
|
39
|
+
#[test]
|
|
40
|
+
#[serial(env)]
|
|
41
|
+
fn issue345_dynamic_cursor_start_persists_its_generated_mcp_path() {
|
|
42
|
+
let _hermetic = hermetic_guard::HermeticTestEnv::enter_with_leader("cursor-dynamic-mcp");
|
|
43
|
+
let (ws, _home, _guard, team) = seed_cursor("dynamic-mcp");
|
|
44
|
+
std::fs::write(
|
|
45
|
+
team.join("agents/cursor_writer.md"),
|
|
46
|
+
"---\nprovider: fake\ndangerously_skip_permissions: true\n---\nControl seat.\n",
|
|
47
|
+
)
|
|
48
|
+
.unwrap();
|
|
49
|
+
let transport = OfflineTransport::new();
|
|
50
|
+
quick_start_with_transport_in_workspace(&ws, &team, None, true, Some("cursortm"), &transport)
|
|
51
|
+
.unwrap();
|
|
52
|
+
let role = team.join("cursor_dynamic.md");
|
|
53
|
+
std::fs::write(&role, "---\nprovider: cursor_agent\nmodel: grok-4.7-high\nauth_mode: subscription\ndangerously_skip_permissions: true\n---\nDynamic Cursor seat.\n").unwrap();
|
|
54
|
+
crate::lifecycle::add_agent_with_transport(
|
|
55
|
+
&team,
|
|
56
|
+
&AgentId::new("cursor_dynamic"),
|
|
57
|
+
&role,
|
|
58
|
+
false,
|
|
59
|
+
Some("cursortm"),
|
|
60
|
+
&transport,
|
|
61
|
+
)
|
|
62
|
+
.unwrap();
|
|
63
|
+
let state = load_runtime_state(&ws).unwrap();
|
|
64
|
+
let expected = ws.join(".team/runtime/mcp/cursor_dynamic.json");
|
|
65
|
+
assert!(
|
|
66
|
+
expected.is_file(),
|
|
67
|
+
"positive control: spawn generated the real config"
|
|
68
|
+
);
|
|
69
|
+
assert_eq!(
|
|
70
|
+
state["agents"]["cursor_dynamic"]["mcp_config"].as_str(),
|
|
71
|
+
expected.to_str()
|
|
72
|
+
);
|
|
73
|
+
assert_eq!(
|
|
74
|
+
state["teams"]["cursortm"]["agents"]["cursor_dynamic"]["mcp_config"].as_str(),
|
|
75
|
+
expected.to_str()
|
|
76
|
+
);
|
|
77
|
+
let config: serde_json::Value =
|
|
78
|
+
serde_json::from_slice(&std::fs::read(&expected).unwrap()).unwrap();
|
|
79
|
+
assert_eq!(
|
|
80
|
+
config["mcpServers"]["team_orchestrator"]["env"]["TEAM_AGENT_ID"],
|
|
81
|
+
"cursor_dynamic"
|
|
82
|
+
);
|
|
83
|
+
assert_eq!(
|
|
84
|
+
config["mcpServers"]["team_orchestrator"]["env"]["TEAM_AGENT_OWNER_TEAM_ID"],
|
|
85
|
+
"cursortm"
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
#[test]
|
|
90
|
+
#[serial(env)]
|
|
91
|
+
fn issue345_cursor_wakeup_restores_missing_derived_mcp_path() {
|
|
92
|
+
let _hermetic = hermetic_guard::HermeticTestEnv::enter_with_leader("cursor-wakeup-mcp");
|
|
93
|
+
let (ws, _home, _guard, team) = seed_cursor("wakeup-mcp");
|
|
94
|
+
let transport = OfflineTransport::new();
|
|
95
|
+
quick_start_with_transport_in_workspace(&ws, &team, None, true, Some("cursortm"), &transport)
|
|
96
|
+
.unwrap();
|
|
97
|
+
crate::lifecycle::restart::stop_agent_with_transport(
|
|
98
|
+
&ws,
|
|
99
|
+
&AgentId::new("cursor_writer"),
|
|
100
|
+
Some("cursortm"),
|
|
101
|
+
&transport,
|
|
102
|
+
)
|
|
103
|
+
.unwrap();
|
|
104
|
+
let mut state = load_runtime_state(&ws).unwrap();
|
|
105
|
+
state["agents"]["cursor_writer"]
|
|
106
|
+
.as_object_mut()
|
|
107
|
+
.unwrap()
|
|
108
|
+
.remove("mcp_config");
|
|
109
|
+
state["teams"]["cursortm"]["agents"]["cursor_writer"]
|
|
110
|
+
.as_object_mut()
|
|
111
|
+
.unwrap()
|
|
112
|
+
.remove("mcp_config");
|
|
113
|
+
// Plant an actual legacy row; ordinary state saves intentionally backfill fields.
|
|
114
|
+
std::fs::write(
|
|
115
|
+
crate::state::persist::runtime_state_path(&ws),
|
|
116
|
+
serde_json::to_vec(&state).unwrap(),
|
|
117
|
+
)
|
|
118
|
+
.unwrap();
|
|
119
|
+
assert!(load_runtime_state(&ws).unwrap()["agents"]["cursor_writer"]
|
|
120
|
+
.get("mcp_config")
|
|
121
|
+
.is_none());
|
|
122
|
+
crate::lifecycle::restart::start_agent_with_transport(
|
|
123
|
+
&ws,
|
|
124
|
+
&AgentId::new("cursor_writer"),
|
|
125
|
+
false,
|
|
126
|
+
false,
|
|
127
|
+
true,
|
|
128
|
+
Some("cursortm"),
|
|
129
|
+
&transport,
|
|
130
|
+
)
|
|
131
|
+
.unwrap();
|
|
132
|
+
let after = load_runtime_state(&ws).unwrap();
|
|
133
|
+
let expected = ws.join(".team/runtime/mcp/cursor_writer.json");
|
|
134
|
+
assert!(expected.is_file());
|
|
135
|
+
assert_eq!(
|
|
136
|
+
after["agents"]["cursor_writer"]["mcp_config"].as_str(),
|
|
137
|
+
expected.to_str()
|
|
138
|
+
);
|
|
139
|
+
assert_eq!(
|
|
140
|
+
after["teams"]["cursortm"]["agents"]["cursor_writer"]["mcp_config"].as_str(),
|
|
141
|
+
expected.to_str()
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
39
145
|
#[test]
|
|
40
146
|
#[serial(env)]
|
|
41
147
|
fn cursor_fresh_spawn_does_not_invent_session_id() {
|
|
@@ -109,7 +215,23 @@ fn cursor_restart_uses_resume_when_session_captured_and_archive_present() {
|
|
|
109
215
|
primary.to_string_lossy().as_ref()
|
|
110
216
|
);
|
|
111
217
|
patch_cursor_writer(&mut state, sid, &dir, &primary);
|
|
112
|
-
|
|
218
|
+
state["agents"]["cursor_writer"]
|
|
219
|
+
.as_object_mut()
|
|
220
|
+
.unwrap()
|
|
221
|
+
.remove("mcp_config");
|
|
222
|
+
state["teams"]["cursortm"]["agents"]["cursor_writer"]
|
|
223
|
+
.as_object_mut()
|
|
224
|
+
.unwrap()
|
|
225
|
+
.remove("mcp_config");
|
|
226
|
+
// Preserve the real captured SID while removing only the derived config path.
|
|
227
|
+
std::fs::write(
|
|
228
|
+
crate::state::persist::runtime_state_path(&ws),
|
|
229
|
+
serde_json::to_vec(&state).unwrap(),
|
|
230
|
+
)
|
|
231
|
+
.unwrap();
|
|
232
|
+
assert!(load_runtime_state(&ws).unwrap()["agents"]["cursor_writer"]
|
|
233
|
+
.get("mcp_config")
|
|
234
|
+
.is_none());
|
|
113
235
|
|
|
114
236
|
seed_healthy_coordinator(&ws);
|
|
115
237
|
|
|
@@ -135,6 +257,15 @@ fn cursor_restart_uses_resume_when_session_captured_and_archive_present() {
|
|
|
135
257
|
primary.to_string_lossy().as_ref()
|
|
136
258
|
);
|
|
137
259
|
assert_eq!(after["agents"]["cursor_writer"]["session_id"], sid);
|
|
260
|
+
let expected_config = ws.join(".team/runtime/mcp/cursor_writer.json");
|
|
261
|
+
assert_eq!(
|
|
262
|
+
after["agents"]["cursor_writer"]["mcp_config"].as_str(),
|
|
263
|
+
expected_config.to_str()
|
|
264
|
+
);
|
|
265
|
+
assert_eq!(
|
|
266
|
+
after["teams"]["cursortm"]["agents"]["cursor_writer"]["mcp_config"].as_str(),
|
|
267
|
+
expected_config.to_str()
|
|
268
|
+
);
|
|
138
269
|
assert_eq!(
|
|
139
270
|
cursor_mcp_project_dir(&ws, "cursor_writer").unwrap(),
|
|
140
271
|
primary_before
|
|
@@ -651,10 +651,10 @@ fn contract_b_delivery_gate_trusts_startup_handled_state_over_pane_scrollback()
|
|
|
651
651
|
"SUB step2 probe: handled startup trust must deliver despite scrollback residue.",
|
|
652
652
|
None,
|
|
653
653
|
true,
|
|
654
|
-
|
|
654
|
+
Some("subprep-scrollback"),
|
|
655
655
|
)
|
|
656
656
|
.unwrap();
|
|
657
|
-
let handled_state = json!({
|
|
657
|
+
let mut handled_state = json!({
|
|
658
658
|
"session_name": "team-ta-subroot-SUB-PROBE-2-send-turn-idle",
|
|
659
659
|
"agents": {
|
|
660
660
|
"w1": {
|
|
@@ -670,9 +670,15 @@ fn contract_b_delivery_gate_trusts_startup_handled_state_over_pane_scrollback()
|
|
|
670
670
|
}
|
|
671
671
|
}
|
|
672
672
|
});
|
|
673
|
+
#[cfg(unix)]
|
|
674
|
+
let pane = crate::mcp_server::readiness::test_ready_worker(
|
|
675
|
+
&handled_ws, &mut handled_state, "subprep-scrollback", "w1", "%7",
|
|
676
|
+
);
|
|
673
677
|
team_agent::state::persist::save_runtime_state(&handled_ws, &handled_state).unwrap();
|
|
674
678
|
let handled_transport =
|
|
675
679
|
RecordingTransport::new(vec![SUBROOT_FULL_TRUST_PANE_AFTER_QUICKSTART.to_string()]);
|
|
680
|
+
#[cfg(unix)]
|
|
681
|
+
{ handled_transport.state.lock().unwrap().carrier = pane.pane_pid; }
|
|
676
682
|
let handled_log = EventLog::new(&handled_ws);
|
|
677
683
|
let handled = deliver_pending_message(
|
|
678
684
|
&handled_ws,
|
|
@@ -273,9 +273,6 @@ pub(crate) fn preflight(
|
|
|
273
273
|
let Some(agent) = state.get("agents").and_then(|agents| agents.get(agent_id)) else {
|
|
274
274
|
return Ok(None);
|
|
275
275
|
};
|
|
276
|
-
let Some(config) = agent.get("mcp_config").and_then(Value::as_str) else {
|
|
277
|
-
return Ok(None);
|
|
278
|
-
};
|
|
279
276
|
let provider = agent
|
|
280
277
|
.get("provider")
|
|
281
278
|
.and_then(Value::as_str)
|
|
@@ -284,6 +281,11 @@ pub(crate) fn preflight(
|
|
|
284
281
|
if !crate::provider::get_adapter(provider).requires_worker_mcp_handshake() {
|
|
285
282
|
return Ok(None);
|
|
286
283
|
}
|
|
284
|
+
let config = agent
|
|
285
|
+
.get("mcp_config")
|
|
286
|
+
.and_then(Value::as_str)
|
|
287
|
+
.filter(|path| !path.trim().is_empty())
|
|
288
|
+
.ok_or("mcp_handshake_config_missing")?;
|
|
287
289
|
let owner = owner
|
|
288
290
|
.filter(|s| !s.is_empty())
|
|
289
291
|
.ok_or("mcp_handshake_scope_missing")?;
|
|
@@ -427,6 +429,67 @@ pub(crate) fn test_carrier_pid() -> Option<u32> {
|
|
|
427
429
|
crate::platform::process::current_parent_pid()
|
|
428
430
|
}
|
|
429
431
|
|
|
432
|
+
/// Bind a controlled live test helper through the real receipt publisher. This
|
|
433
|
+
/// does not bypass preflight or fabricate a process/image/ancestry receipt.
|
|
434
|
+
#[cfg(test)]
|
|
435
|
+
pub(crate) fn test_ready_worker(
|
|
436
|
+
workspace: &Path,
|
|
437
|
+
state: &mut Value,
|
|
438
|
+
owner: &str,
|
|
439
|
+
agent: &str,
|
|
440
|
+
pane: &str,
|
|
441
|
+
) -> PaneInfo {
|
|
442
|
+
let workspace = workspace.canonicalize().unwrap();
|
|
443
|
+
let carrier = test_carrier_pid().unwrap();
|
|
444
|
+
let config = workspace
|
|
445
|
+
.join(".team/runtime/mcp")
|
|
446
|
+
.join(format!("{agent}.json"));
|
|
447
|
+
std::fs::create_dir_all(config.parent().unwrap()).unwrap();
|
|
448
|
+
std::fs::write(&config, serde_json::to_vec(&serde_json::json!({
|
|
449
|
+
"mcpServers": {"team_orchestrator": {
|
|
450
|
+
"command": std::env::current_exe().unwrap().canonicalize().unwrap(),
|
|
451
|
+
"env": {"TEAM_AGENT_WORKSPACE": workspace, "TEAM_AGENT_ID": agent, "TEAM_AGENT_OWNER_TEAM_ID": owner}
|
|
452
|
+
}}
|
|
453
|
+
})).unwrap()).unwrap();
|
|
454
|
+
state["team_key"] = serde_json::json!(owner);
|
|
455
|
+
state["active_team_key"] = serde_json::json!(owner);
|
|
456
|
+
let row = &mut state["agents"][agent];
|
|
457
|
+
row["mcp_config"] = serde_json::json!(config);
|
|
458
|
+
row["pane_id"] = serde_json::json!(pane);
|
|
459
|
+
row["pane_pid"] = serde_json::json!(carrier);
|
|
460
|
+
row["spawned_at"] =
|
|
461
|
+
serde_json::json!((Utc::now() - chrono::Duration::seconds(10)).to_rfc3339());
|
|
462
|
+
let window = row["window"].as_str().unwrap_or(agent).to_string();
|
|
463
|
+
let session = state["session_name"].as_str().unwrap().to_string();
|
|
464
|
+
if state["teams"][owner].is_object() {
|
|
465
|
+
state["teams"][owner]["agents"][agent] = state["agents"][agent].clone();
|
|
466
|
+
}
|
|
467
|
+
publish(&workspace, owner, agent, Utc::now()).unwrap();
|
|
468
|
+
let target = PaneInfo {
|
|
469
|
+
pane_id: crate::transport::PaneId::new(pane),
|
|
470
|
+
session: crate::transport::SessionName::new(session),
|
|
471
|
+
window_index: None,
|
|
472
|
+
window_name: Some(crate::transport::WindowName::new(window)),
|
|
473
|
+
pane_index: None,
|
|
474
|
+
tty: None,
|
|
475
|
+
current_command: None,
|
|
476
|
+
current_path: Some(workspace.clone()),
|
|
477
|
+
active: true,
|
|
478
|
+
pane_pid: Some(carrier),
|
|
479
|
+
leader_env: Default::default(),
|
|
480
|
+
};
|
|
481
|
+
let admission = preflight(&workspace, Some(owner), agent, state)
|
|
482
|
+
.unwrap()
|
|
483
|
+
.unwrap();
|
|
484
|
+
revalidate(
|
|
485
|
+
&admission,
|
|
486
|
+
&Target::Pane(target.pane_id.clone()),
|
|
487
|
+
std::slice::from_ref(&target),
|
|
488
|
+
)
|
|
489
|
+
.unwrap();
|
|
490
|
+
target
|
|
491
|
+
}
|
|
492
|
+
|
|
430
493
|
#[cfg(test)]
|
|
431
494
|
#[path = "readiness_tests.rs"]
|
|
432
495
|
mod tests;
|
|
@@ -94,6 +94,29 @@ impl Drop for Case {
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
#[test]
|
|
98
|
+
fn issue345_missing_mcp_config_does_not_disable_eager_admission() {
|
|
99
|
+
for provider in ["cursor_agent", "codex", "claude"] {
|
|
100
|
+
let mut case = Case::new();
|
|
101
|
+
case.state["agents"]["worker"]["provider"] = json!(provider);
|
|
102
|
+
case.state["agents"]["worker"]
|
|
103
|
+
.as_object_mut()
|
|
104
|
+
.unwrap()
|
|
105
|
+
.remove("mcp_config");
|
|
106
|
+
assert!(
|
|
107
|
+
case.admission().is_err(),
|
|
108
|
+
"missing config cannot disable {provider} admission"
|
|
109
|
+
);
|
|
110
|
+
for value in [Value::Null, json!(false), json!(""), json!(" ")] {
|
|
111
|
+
case.state["agents"]["worker"]["mcp_config"] = value;
|
|
112
|
+
assert!(
|
|
113
|
+
case.admission().is_err(),
|
|
114
|
+
"invalid config cannot disable {provider} admission"
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
97
120
|
#[test]
|
|
98
121
|
fn missing_handshake_defers_then_a_live_scoped_receipt_admits() {
|
|
99
122
|
let case = Case::new();
|
|
@@ -238,5 +261,12 @@ fn fake_and_pi_keep_their_existing_non_eager_or_runtime_registry_admission() {
|
|
|
238
261
|
assert!(preflight(&case.ws, Some("owner"), "worker", &state)
|
|
239
262
|
.unwrap()
|
|
240
263
|
.is_none());
|
|
264
|
+
state["agents"]["worker"]
|
|
265
|
+
.as_object_mut()
|
|
266
|
+
.unwrap()
|
|
267
|
+
.remove("mcp_config");
|
|
268
|
+
assert!(preflight(&case.ws, Some("owner"), "worker", &state)
|
|
269
|
+
.unwrap()
|
|
270
|
+
.is_none());
|
|
241
271
|
}
|
|
242
272
|
}
|
|
@@ -122,9 +122,20 @@ fn successful_transport_report_does_not_override_unconsumed_composer() {
|
|
|
122
122
|
"[Pasted text #1 +25 lines]",
|
|
123
123
|
"foreign draft",
|
|
124
124
|
] {
|
|
125
|
-
let f = Fixture::new("cursor_agent");
|
|
125
|
+
let mut f = Fixture::new("cursor_agent");
|
|
126
|
+
#[cfg(unix)]
|
|
127
|
+
let panes = vec![crate::mcp_server::readiness::test_ready_worker(
|
|
128
|
+
&f.workspace,
|
|
129
|
+
&mut f.state,
|
|
130
|
+
"team",
|
|
131
|
+
"worker",
|
|
132
|
+
"%1",
|
|
133
|
+
)];
|
|
134
|
+
#[cfg(not(unix))]
|
|
135
|
+
let panes = f.transport().list_targets().unwrap();
|
|
126
136
|
let t = f
|
|
127
137
|
.transport()
|
|
138
|
+
.with_targets(panes)
|
|
128
139
|
.with_native_input_observation(frame(composer, ""));
|
|
129
140
|
let out = f.deliver(&t);
|
|
130
141
|
assert!(!out.ok, "composer={composer}");
|
|
@@ -66,6 +66,30 @@ fn eager_mcp_waits_without_spending_input_attempts_then_delivers_the_same_messag
|
|
|
66
66
|
assert_eq!(transport.inject_count(), 1, "same queued row proceeds after the real receipt");
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
#[test]
|
|
70
|
+
#[cfg(unix)]
|
|
71
|
+
fn issue345_missing_eager_config_defers_without_input_or_attempts() {
|
|
72
|
+
let mut case = WorkerCase::new("missing-mcp-config", "cursor_agent");
|
|
73
|
+
let carrier = crate::mcp_server::readiness::test_carrier_pid().unwrap();
|
|
74
|
+
case.state["team_key"] = serde_json::json!("dup-team");
|
|
75
|
+
case.state["agents"]["w1"]["pane_pid"] = serde_json::json!(carrier);
|
|
76
|
+
case.state["agents"]["w1"]["spawned_at"] = serde_json::json!(chrono::Utc::now().to_rfc3339());
|
|
77
|
+
case.state["teams"] = serde_json::json!({"dup-team": case.state.clone()});
|
|
78
|
+
crate::state::persist::save_runtime_state(&case.workspace, &case.state).unwrap();
|
|
79
|
+
let transport = RecordingTransport { carrier: Some(carrier), ..RecordingTransport::new("") };
|
|
80
|
+
let message_id = case.store.create_message(None, "leader", "w1", "missing config canary", None, false, Some("dup-team")).unwrap();
|
|
81
|
+
for _ in 0..3 {
|
|
82
|
+
let outcome = deliver_pending_message(&case.workspace, &case.store, &transport,
|
|
83
|
+
&message_id, &case.event_log, &case.state).unwrap();
|
|
84
|
+
assert!(!outcome.ok);
|
|
85
|
+
assert!(outcome.verification.as_deref().unwrap().starts_with("mcp_handshake_"));
|
|
86
|
+
assert_eq!(transport.inject_count(), 0, "missing metadata must never reach input");
|
|
87
|
+
}
|
|
88
|
+
let connection = rusqlite::Connection::open(case.store.db_path()).unwrap();
|
|
89
|
+
let attempts: i64 = connection.query_row("select delivery_attempts from messages where message_id=?1", [&message_id], |row| row.get(0)).unwrap();
|
|
90
|
+
assert_eq!(attempts, 0, "metadata wait is not an input attempt");
|
|
91
|
+
}
|
|
92
|
+
|
|
69
93
|
#[test]
|
|
70
94
|
fn error_retry_claim_is_single_winner_across_two_delivers() {
|
|
71
95
|
let case = WorkerCase::new("claim-gate", "fake");
|
|
@@ -141,10 +165,30 @@ fn observer_state_save_failure_after_enter_does_not_return_err() {
|
|
|
141
165
|
|
|
142
166
|
#[test]
|
|
143
167
|
fn pane_token_refuses_second_inject_for_claude_grok_cursor() {
|
|
144
|
-
for provider in ["claude", "grok", "
|
|
145
|
-
let case = WorkerCase::new(&format!("token-{provider}"), provider);
|
|
146
|
-
|
|
147
|
-
let
|
|
168
|
+
for provider in ["claude", "grok", "cursor_agent"] {
|
|
169
|
+
let mut case = WorkerCase::new(&format!("token-{provider}"), provider);
|
|
170
|
+
#[cfg(unix)]
|
|
171
|
+
let carrier = crate::mcp_server::readiness::test_carrier_pid().unwrap();
|
|
172
|
+
#[cfg(not(unix))]
|
|
173
|
+
let carrier = std::process::id();
|
|
174
|
+
let config = case.workspace.join(".team/runtime/mcp/w1.json");
|
|
175
|
+
std::fs::create_dir_all(config.parent().unwrap()).unwrap();
|
|
176
|
+
std::fs::write(&config, serde_json::to_vec(&serde_json::json!({
|
|
177
|
+
"mcpServers": {"team_orchestrator": {
|
|
178
|
+
"command": std::env::current_exe().unwrap().canonicalize().unwrap(),
|
|
179
|
+
"env": {"TEAM_AGENT_WORKSPACE": case.workspace, "TEAM_AGENT_ID": "w1", "TEAM_AGENT_OWNER_TEAM_ID": "dup-team"}
|
|
180
|
+
}}
|
|
181
|
+
})).unwrap()).unwrap();
|
|
182
|
+
case.state["team_key"] = serde_json::json!("dup-team");
|
|
183
|
+
case.state["agents"]["w1"]["mcp_config"] = serde_json::json!(config);
|
|
184
|
+
case.state["agents"]["w1"]["pane_pid"] = serde_json::json!(carrier);
|
|
185
|
+
case.state["agents"]["w1"]["spawned_at"] = serde_json::json!((chrono::Utc::now() - chrono::Duration::seconds(10)).to_rfc3339());
|
|
186
|
+
case.state["teams"] = serde_json::json!({"dup-team": case.state.clone()});
|
|
187
|
+
crate::state::persist::save_runtime_state(&case.workspace, &case.state).unwrap();
|
|
188
|
+
#[cfg(unix)]
|
|
189
|
+
crate::mcp_server::readiness::publish(&case.workspace, "dup-team", "w1", chrono::Utc::now()).unwrap();
|
|
190
|
+
let transport = RecordingTransport { carrier: Some(carrier), ..RecordingTransport::new("") };
|
|
191
|
+
let message_id = case.store.create_message(None, "leader", "w1", &format!("{provider} canary"), None, false, Some("dup-team")).unwrap();
|
|
148
192
|
let first = deliver_pending_message(
|
|
149
193
|
&case.workspace,
|
|
150
194
|
&case.store,
|
|
@@ -169,7 +169,10 @@ fn p2_scheduler_unknown_kind_surfaces_error() {
|
|
|
169
169
|
|
|
170
170
|
/// A delivery transport whose `inject` RECORDS-and-succeeds (the real deliver loop legitimately
|
|
171
171
|
/// injects; the §84-guard NoopTransport panics). Only `inject` is reached by deliver_pending_message.
|
|
172
|
-
|
|
172
|
+
#[derive(Default)]
|
|
173
|
+
struct DeliverOkTransport {
|
|
174
|
+
targets: Vec<PaneInfo>,
|
|
175
|
+
}
|
|
173
176
|
impl Transport for DeliverOkTransport {
|
|
174
177
|
fn kind(&self) -> BackendKind {
|
|
175
178
|
BackendKind::Tmux
|
|
@@ -232,7 +235,7 @@ impl Transport for DeliverOkTransport {
|
|
|
232
235
|
Ok(PaneLiveness::Unknown)
|
|
233
236
|
}
|
|
234
237
|
fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
|
|
235
|
-
Ok(
|
|
238
|
+
Ok(self.targets.clone())
|
|
236
239
|
}
|
|
237
240
|
fn has_session(&self, _s: &SessionName) -> Result<bool, TransportError> {
|
|
238
241
|
Ok(true)
|
|
@@ -333,9 +336,9 @@ fn spine_delivery_skips_queued_statuses() {
|
|
|
333
336
|
.unwrap();
|
|
334
337
|
set_message_status(&store, &qidle, "queued_until_idle");
|
|
335
338
|
set_message_status(&store, &qstart, "queued_until_start");
|
|
336
|
-
let state = serde_json::json!({"agents": {"w1": {}}});
|
|
339
|
+
let state = serde_json::json!({"agents": {"w1": {"provider": "fake"}}});
|
|
337
340
|
|
|
338
|
-
let _ = deliver_pending_messages(&ws, &state, &DeliverOkTransport, &log).unwrap();
|
|
341
|
+
let _ = deliver_pending_messages(&ws, &state, &DeliverOkTransport::default(), &log).unwrap();
|
|
339
342
|
|
|
340
343
|
assert_eq!(
|
|
341
344
|
message_status_of(&store, &qidle),
|
|
@@ -359,9 +362,9 @@ fn spine_delivery_busy_recipient_is_injected_without_idle_deferral() {
|
|
|
359
362
|
let mid = store
|
|
360
363
|
.create_message(Some("t"), "leader", "w1", "hi", None, true, None)
|
|
361
364
|
.unwrap();
|
|
362
|
-
let state = serde_json::json!({"agents": {"w1": {"status": "busy"}}});
|
|
365
|
+
let state = serde_json::json!({"agents": {"w1": {"provider": "fake", "status": "busy"}}});
|
|
363
366
|
|
|
364
|
-
let delivered = deliver_pending_messages(&ws, &state, &DeliverOkTransport, &log).unwrap();
|
|
367
|
+
let delivered = deliver_pending_messages(&ws, &state, &DeliverOkTransport::default(), &log).unwrap();
|
|
365
368
|
|
|
366
369
|
assert!(delivered.contains(&mid));
|
|
367
370
|
assert_eq!(message_status_of(&store, &mid), "delivered");
|
|
@@ -380,11 +383,39 @@ fn spine_delivery_pi_cursor_uses_v113_direct_input_without_idle_scope() {
|
|
|
380
383
|
let store = store_for(&ws);
|
|
381
384
|
let log = EventLog::new(&ws);
|
|
382
385
|
let mid = store
|
|
383
|
-
.create_message(Some("t"), "leader", "w1", "hi", None, true,
|
|
386
|
+
.create_message(Some("t"), "leader", "w1", "hi", None, true, Some("spine-team"))
|
|
384
387
|
.unwrap();
|
|
385
|
-
|
|
386
|
-
let
|
|
387
|
-
|
|
388
|
+
#[cfg(unix)]
|
|
389
|
+
let carrier = crate::mcp_server::readiness::test_carrier_pid().unwrap();
|
|
390
|
+
#[cfg(not(unix))]
|
|
391
|
+
let carrier = std::process::id();
|
|
392
|
+
let config = ws.join(".team/runtime/mcp/w1.json");
|
|
393
|
+
std::fs::create_dir_all(config.parent().unwrap()).unwrap();
|
|
394
|
+
std::fs::write(&config, serde_json::to_vec(&serde_json::json!({
|
|
395
|
+
"mcpServers": {"team_orchestrator": {
|
|
396
|
+
"command": std::env::current_exe().unwrap().canonicalize().unwrap(),
|
|
397
|
+
"env": {"TEAM_AGENT_WORKSPACE": ws, "TEAM_AGENT_ID": "w1", "TEAM_AGENT_OWNER_TEAM_ID": "spine-team"}
|
|
398
|
+
}}
|
|
399
|
+
})).unwrap()).unwrap();
|
|
400
|
+
let mut state = serde_json::json!({"team_key": "spine-team", "active_team_key": "spine-team", "session_name": "spine", "agents": {"w1": {
|
|
401
|
+
"provider": provider, "status": "running", "window": "w1", "mcp_config": config,
|
|
402
|
+
"spawned_at": (chrono::Utc::now() - chrono::Duration::seconds(10)).to_rfc3339(),
|
|
403
|
+
"pane_id": "%spine", "pane_pid": carrier
|
|
404
|
+
}}});
|
|
405
|
+
state["teams"] = serde_json::json!({"spine-team": state.clone()});
|
|
406
|
+
crate::state::persist::save_runtime_state(&ws, &state).unwrap();
|
|
407
|
+
#[cfg(unix)]
|
|
408
|
+
if provider == "cursor_agent" {
|
|
409
|
+
crate::mcp_server::readiness::publish(&ws, "spine-team", "w1", chrono::Utc::now()).unwrap();
|
|
410
|
+
}
|
|
411
|
+
let transport = DeliverOkTransport { targets: vec![PaneInfo {
|
|
412
|
+
pane_id: PaneId::new("%spine"), session: SessionName::new("spine"),
|
|
413
|
+
window_index: None, window_name: Some(WindowName::new("w1")), pane_index: None, tty: None,
|
|
414
|
+
current_command: None, current_path: None, active: true,
|
|
415
|
+
pane_pid: Some(carrier), leader_env: BTreeMap::new(),
|
|
416
|
+
}] };
|
|
417
|
+
let delivered = deliver_pending_messages(&ws, &state, &transport, &log).unwrap();
|
|
418
|
+
assert!(delivered.contains(&mid), "{provider}: {:?}", read_event_log(&ws));
|
|
388
419
|
assert_eq!(message_status_of(&store, &mid), "delivered");
|
|
389
420
|
}
|
|
390
421
|
}
|
|
@@ -405,9 +436,9 @@ fn contract_alive_worker_with_working_health_is_deliverable_not_deferred() {
|
|
|
405
436
|
.create_message(Some("t"), "leader", "w1", "hi", None, true, None)
|
|
406
437
|
.unwrap();
|
|
407
438
|
seed_agent_health(&store, "w1", "WORKING"); // turn-level state — must NOT gate delivery
|
|
408
|
-
let state = serde_json::json!({"agents": {"w1": {"status": "running"}}}); // lifecycle: alive
|
|
439
|
+
let state = serde_json::json!({"agents": {"w1": {"provider": "fake", "status": "running"}}}); // lifecycle: alive
|
|
409
440
|
|
|
410
|
-
let delivered = deliver_pending_messages(&ws, &state, &DeliverOkTransport, &log).unwrap();
|
|
441
|
+
let delivered = deliver_pending_messages(&ws, &state, &DeliverOkTransport::default(), &log).unwrap();
|
|
411
442
|
|
|
412
443
|
let events = read_event_log(&ws);
|
|
413
444
|
let deferred: Vec<_> = events
|
|
@@ -530,7 +561,7 @@ fn spine_delivery_injects_resolvable_target_not_bare_agent_pane() {
|
|
|
530
561
|
// in-team, non-busy agent -> deliver proceeds; the session-qualified resolution uses session_name + window.
|
|
531
562
|
let state = serde_json::json!({
|
|
532
563
|
"session_name": "team-x",
|
|
533
|
-
"agents": {"w1": {"status": "idle", "window": "w1"}}
|
|
564
|
+
"agents": {"w1": {"provider": "fake", "status": "idle", "window": "w1"}}
|
|
534
565
|
});
|
|
535
566
|
let transport = OfflineTransport::new();
|
|
536
567
|
|
|
@@ -585,7 +616,7 @@ fn spine_delivery_injects_rendered_protocol_block_not_raw_content() {
|
|
|
585
616
|
.unwrap();
|
|
586
617
|
let state = serde_json::json!({
|
|
587
618
|
"session_name": "team-x",
|
|
588
|
-
"agents": {"w1": {"status": "idle", "window": "w1"}}
|
|
619
|
+
"agents": {"w1": {"provider": "fake", "status": "idle", "window": "w1"}}
|
|
589
620
|
});
|
|
590
621
|
let transport = OfflineTransport::new();
|
|
591
622
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@team-agent/installer",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.122",
|
|
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.122",
|
|
24
|
+
"@team-agent/cli-darwin-x64": "0.5.122",
|
|
25
|
+
"@team-agent/cli-linux-x64": "0.5.122"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"postinstall": "node npm/bincheck.mjs",
|